From bb5cb3283898fdfb7f6c98f13598aedb10fbb2d9 Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:07:53 -0400 Subject: [PATCH 01/92] refactor(honcho): canonicalize identity-mapping on pinUserPeer, migrate legacy key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup wizard wrote the legacy pinPeerName even though pinUserPeer is the canonical key that outranks it in the resolver — so it had to scrub the canonical key afterward to stop it winning. Write pinUserPeer directly and migrate any legacy pinPeerName onto it on touch (setup load + clone), which removes the precedence-fighting entirely. Resolver still reads pinPeerName as a back-compat alias; that's deferred. --- plugins/memory/honcho/cli.py | 50 ++++++++++++++-------- tests/honcho_plugin/test_cli.py | 73 ++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 092b7c823d..bd74f42abd 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -41,22 +41,20 @@ def clone_honcho_for_profile(profile_name: str) -> bool: return False # already exists # Clone settings from default block, override identity fields. - # Identity-mapping keys (pinPeerName/pinUserPeer, userPeerAliases, - # runtimePeerPrefix) carry the operator's runtime-to-peer routing - # intent from #27371. Both pin keys are inherited because - # HonchoClientConfig prefers pinUserPeer over pinPeerName — leaving - # the canonical key off this allowlist silently drops the pin on - # cloned profiles when the default uses the newer name. + # Identity-mapping keys (pinUserPeer, userPeerAliases, runtimePeerPrefix) + # carry the operator's runtime-to-peer routing intent from #27371. new_block = {} for key in ("recallMode", "writeFrequency", "sessionStrategy", "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", "saveMessages", "observation", - "pinPeerName", "pinUserPeer", "userPeerAliases", - "runtimePeerPrefix"): + "pinUserPeer", "userPeerAliases", "runtimePeerPrefix"): val = default_block.get(key) if val is not None: new_block[key] = val + # Carry a legacy default-block pinPeerName forward under the canonical key. + if "pinUserPeer" not in new_block and default_block.get("pinPeerName") is not None: + new_block["pinUserPeer"] = default_block["pinPeerName"] # Inherit peer name from default peer_name = default_block.get("peerName") or cfg.get("peerName") @@ -371,15 +369,28 @@ def _resolve_effective_identity_mapping( def _scrub_identity_mapping(hermes_host: dict) -> None: """Drop every peer-mapping key from the host block. - Called before the wizard writes a chosen shape so latent precedence - conflicts can't survive — e.g. a stray host ``pinUserPeer: false`` - that would silently outrank a freshly written ``pinPeerName: true`` - (host ``pinUserPeer`` is first in the resolver ladder). + Called before the wizard writes a chosen shape so a stale alias, prefix, + or pin from an earlier run can't bleed into the new mapping. """ for key in _IDENTITY_MAPPING_KEYS: hermes_host.pop(key, None) +def _migrate_pin_key(block: dict) -> bool: + """Rewrite a legacy ``pinPeerName`` to canonical ``pinUserPeer`` in place. + + ``pinUserPeer`` wins over ``pinPeerName`` in the resolver, so setup writes + only the canonical form and migrates on touch to stop configs carrying + both. Returns True if the block changed. + """ + if "pinPeerName" not in block: + return False + legacy = block.pop("pinPeerName") + if "pinUserPeer" not in block: + block["pinUserPeer"] = legacy + return True + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -446,6 +457,10 @@ def cmd_setup(args) -> None: hosts = cfg.setdefault("hosts", {}) hermes_host = hosts.setdefault(_host_key(), {}) + # Canonicalize any legacy pinPeerName before detection/writes. + _migrate_pin_key(cfg) + _migrate_pin_key(hermes_host) + # --- 1. Cloud or local? --- print(" Deployment:") print(" cloud -- Honcho cloud (api.honcho.dev)") @@ -599,12 +614,11 @@ def cmd_setup(args) -> None: new_shape = "skip" # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale ``pinUserPeer`` left behind by an earlier setup run can't - # outrank the freshly written ``pinPeerName`` via host-level precedence. + # so a stale alias/prefix/pin from an earlier run starts clean. if new_shape == "single": _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = True - print(f" pinPeerName=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") + hermes_host["pinUserPeer"] = True + print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") elif new_shape == "multi": # Preserve operator-curated, host-level aliases so multi → multi # re-runs don't drop them. Root-sourced aliases are left to @@ -615,7 +629,7 @@ def cmd_setup(args) -> None: else {} ) _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False # Do NOT auto-write ``userPeerAliases: {}``: an empty host map # would override any root-level ``userPeerAliases`` the operator # set as a cross-host baseline, silently disabling those aliases. @@ -642,7 +656,7 @@ def cmd_setup(args) -> None: # the mapping". existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} _scrub_identity_mapping(hermes_host) - hermes_host["pinPeerName"] = False + hermes_host["pinUserPeer"] = False peer_target = hermes_host.get("peerName") or current_peer or "user" print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 74b7e1bc34..fcbce52703 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -239,7 +239,7 @@ class TestCloneHonchoForProfile: """Identity-key carryover during profile cloning. The host-scoped identity-mapping keys (``userPeerAliases``, - ``runtimePeerPrefix``, ``pinPeerName``) must survive a clone; otherwise + ``runtimePeerPrefix``, ``pinUserPeer``) must survive a clone; otherwise the new profile silently fragments memory by resolving gateway users to raw runtime IDs instead of operator-declared peers. """ @@ -290,7 +290,7 @@ class TestCloneHonchoForProfile: new_block = written["cfg"]["hosts"]["hermes_coder"] assert new_block["runtimePeerPrefix"] == "telegram_" - def test_pin_peer_name_carries_into_cloned_profile(self, monkeypatch, tmp_path): + def test_legacy_pin_peer_name_migrates_to_canonical_on_clone(self, monkeypatch, tmp_path): cfg = { "apiKey": "***", "hosts": { @@ -304,7 +304,8 @@ class TestCloneHonchoForProfile: ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["pinPeerName"] is True + assert new_block["pinUserPeer"] is True + assert "pinPeerName" not in new_block def test_unset_identity_keys_do_not_appear_in_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -317,6 +318,7 @@ class TestCloneHonchoForProfile: new_block = written["cfg"]["hosts"]["hermes_coder"] assert "userPeerAliases" not in new_block assert "runtimePeerPrefix" not in new_block + assert "pinUserPeer" not in new_block assert "pinPeerName" not in new_block @@ -409,7 +411,7 @@ class TestSetupWizardDeploymentShape: }}, } host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host @@ -424,7 +426,7 @@ class TestSetupWizardDeploymentShape: "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Multi must NOT auto-write ``userPeerAliases: {}``: an empty host # map would silently override a root-level baseline. Absence is # the correct "no host opinion" signal. @@ -446,7 +448,7 @@ class TestSetupWizardDeploymentShape: "", # runtime peer prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { "86701400": "eri", "491827364": "eri", @@ -454,6 +456,8 @@ class TestSetupWizardDeploymentShape: assert "runtimePeerPrefix" not in host def test_skip_shape_preserves_existing_identity_config(self, monkeypatch, tmp_path): + # Seeds the legacy ``pinPeerName``: skip must leave the mapping intact + # except for the on-load migration onto the canonical key. initial_cfg = { "apiKey": "***", "hosts": {"hermes": { @@ -466,7 +470,8 @@ class TestSetupWizardDeploymentShape: "cloud", "", "eri", "hermetika", "hermes", "skip", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" @@ -494,7 +499,7 @@ class TestSetupWizardDeploymentShape: "", # runtime prefix (skip) ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): @@ -512,7 +517,7 @@ class TestSetupWizardDeploymentShape: "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" @@ -535,10 +540,9 @@ class TestSetupWizardDeploymentShape: # exercise that fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - # Scrub-then-write normalises onto pinPeerName and drops the alias - # so resolver precedence can't reintroduce ambiguity. - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + # Scrub-then-write normalises onto the canonical pinUserPeer. + assert host["pinUserPeer"] is True + assert "pinPeerName" not in host def test_host_pin_user_peer_false_overrides_root_pin_peer_name( self, monkeypatch, tmp_path @@ -558,8 +562,8 @@ class TestSetupWizardDeploymentShape: } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is False + assert "pinPeerName" not in host def test_root_user_peer_aliases_detected_as_hybrid(self, monkeypatch, tmp_path): """Root-level ``userPeerAliases`` must classify as ``hybrid`` even @@ -572,7 +576,7 @@ class TestSetupWizardDeploymentShape: } answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} @@ -584,7 +588,7 @@ class TestSetupWizardDeploymentShape: Picking ``multi`` here is an active choice — detection would have defaulted to ``hybrid`` because root aliases exist — so the operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinPeerName: false`` only, and rely + We honor that by writing ``pinUserPeer: false`` only, and rely on the host's absence of ``userPeerAliases`` to inherit root. That inheritance is intentional: a true wipe would require the operator to delete the root key explicitly. @@ -599,14 +603,12 @@ class TestSetupWizardDeploymentShape: "multi", # explicit multi override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is False + assert host["pinUserPeer"] is False assert "userPeerAliases" not in host def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must drop any host-level ``pinUserPeer``, - otherwise an existing ``pinUserPeer: false`` would outrank the - freshly written ``pinPeerName: true`` and leave the profile - effectively unpinned (the P1 latent-precedence regression). + """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { "apiKey": "***", @@ -620,8 +622,7 @@ class TestSetupWizardDeploymentShape: "single", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) - assert host["pinPeerName"] is True - assert "pinUserPeer" not in host + assert host["pinUserPeer"] is True class TestCloneCarriesPinUserPeer: @@ -653,3 +654,27 @@ class TestCloneCarriesPinUserPeer: assert ok is True new_block = written["cfg"]["hosts"]["hermes_partner"] assert new_block["pinUserPeer"] is True + + +class TestMigratePinKey: + """``_migrate_pin_key`` rewrites the legacy ``pinPeerName`` onto the + canonical ``pinUserPeer`` in place, without clobbering an existing + canonical value.""" + + def test_legacy_key_renamed_to_canonical(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": True} + + def test_canonical_key_wins_when_both_present(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinPeerName": True, "pinUserPeer": False} + assert honcho_cli._migrate_pin_key(block) is True + assert block == {"pinUserPeer": False} + + def test_noop_when_no_legacy_key(self): + import plugins.memory.honcho.cli as honcho_cli + block = {"pinUserPeer": True} + assert honcho_cli._migrate_pin_key(block) is False + assert block == {"pinUserPeer": True} From d7dfeed6dc4218f51176dcd31ca2f4b926d5e89a Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:14:24 -0400 Subject: [PATCH 02/92] feat(honcho-setup): replace deployment-shape prompt with gateway-gated identity tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single/multi/hybrid 'deployment shape' was a misnomer: these keys only affect the gateway (the one entrypoint supplying a runtime user ID), and the three preset names stamped a lossy taxonomy onto three orthogonal knobs while hiding which keys got written. Replace it with an intent-led tree gated on gateway detection: - _gateway_platforms() lazily inspects the gateway config (best-effort, no hard dependency); the step auto-skips when no platform is connected. - 'who talks to this?' → just me / me+others (pooled?) / only others, deriving pinUserPeer + userPeerAliases + runtimePeerPrefix and echoing the result. - [e] drops to a raw-knob editor for power users. - The single→multi orphan guard survives as a pooling steer. --- plugins/memory/honcho/cli.py | 310 +++++++++++++++++++++----------- tests/honcho_plugin/test_cli.py | 148 ++++++++++----- 2 files changed, 303 insertions(+), 155 deletions(-) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index bd74f42abd..33edcf12dc 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -391,6 +391,100 @@ def _migrate_pin_key(block: dict) -> bool: return True +def _gateway_platforms() -> list[str] | None: + """Connected gateway platforms, or None if undetectable. + + Identity mapping only affects gateway runtime users, so setup gates the + whole step on this. Best-effort and dependency-free: the memory plugin + must not hard-depend on the gateway package, so the import is lazy and + guarded (matching the idiom hermes_cli already uses for gateway refs). + """ + try: + from gateway.config import load_gateway_config + return [p.value for p in load_gateway_config().get_connected_platforms()] + except Exception: + return None + + +def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: + """Prompt for the operator's per-platform runtime IDs, aliasing each to + ``peer_target``. Existing entries are preserved.""" + aliases = dict(existing) + print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") + print(" Leave blank to skip a platform. Existing aliases are preserved.") + for platform_label, alias_hint in ( + ("Telegram UID", "e.g. 86701400"), + ("Discord snowflake", "e.g. 491827364"), + ("Slack user ID", "e.g. U04ABCDEF"), + ("Matrix MXID", "e.g. @you:matrix.org"), + ): + entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() + if entered: + aliases[entered] = peer_target + return aliases + + +def _apply_runtime_prefix( + hermes_host: dict, current_prefix: str, prefix_from_root: bool, label: str +) -> None: + """Write a host-level runtimePeerPrefix only when it diverges from an + inherited root value; otherwise let the root cascade stand.""" + new_prefix = _prompt(label, default=current_prefix or "").strip() + if new_prefix and not (prefix_from_root and new_prefix == current_prefix): + hermes_host["runtimePeerPrefix"] = new_prefix + + +def _echo_identity_mapping(hermes_host: dict) -> None: + """Show the resulting keys so the operator can verify what was written.""" + aliases = hermes_host.get("userPeerAliases") + prefix = hermes_host.get("runtimePeerPrefix") + print(" resolved →") + print(f" pinUserPeer = {bool(hermes_host.get('pinUserPeer'))}") + print(f" userPeerAliases = {aliases if aliases else '{}'}") + print(f" runtimePeerPrefix = {prefix if prefix else '(none)'}") + + +def _configure_raw_identity_mapping( + hermes_host: dict, + current_pin: bool, + current_aliases: dict, + current_prefix: str, + aliases_from_root: bool, + prefix_from_root: bool, +) -> None: + """Power-user escape hatch: set the three resolver knobs directly.""" + print("\n Raw identity-mapping keys (resolver tries them top-down):") + pin_in = _prompt( + "pinUserPeer — pin all gateway users to your peer? (true/false)", + default=str(bool(current_pin)).lower(), + ).strip().lower() + pin = pin_in in {"true", "t", "yes", "y", "1"} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = pin + if pin: + return + aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + print(" userPeerAliases — 'runtime_id=peer' pairs (blank line to finish):") + while True: + entry = _prompt(" alias", default="").strip() + if not entry: + break + if "=" in entry: + rid, peer = (p.strip() for p in entry.split("=", 1)) + if rid and peer: + aliases[rid] = peer + if aliases: + hermes_host["userPeerAliases"] = aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "runtimePeerPrefix — namespace for unknown IDs (blank for none)", + ) + + def _prompt(label: str, default: str | None = None, secret: bool = False) -> str: suffix = f" [{default}]" if default else "" sys.stdout.write(f" {label}{suffix}: ") @@ -560,18 +654,15 @@ def cmd_setup(args) -> None: if new_workspace: hermes_host["workspace"] = new_workspace - # --- 3b. Deployment shape --- - # Determines how runtime user identities (Telegram UIDs, Discord - # snowflakes, etc.) map to Honcho peers in gateway sessions. Three - # shapes cover the realistic deployments; each writes a different - # combination of pinPeerName / userPeerAliases / runtimePeerPrefix. - # See plugins/memory/honcho/README.md for the resolver ladder. + # --- 3b. Gateway identity mapping --- + # These keys only affect the Hermes GATEWAY (Telegram/Discord/Slack/...), + # the one entrypoint that supplies a runtime user ID. CLI/TUI/desktop/ACP + # sessions have no runtime ID and fall through to peerName, so the step is + # moot off-gateway — gate it behind detection. # - # Detection must mirror the gateway resolver: root-level config and - # ``pinUserPeer`` (which outranks ``pinPeerName`` at the same level) - # both affect effective routing, so reading host-only fields would - # mis-classify a profile that inherits its mapping from root or uses - # the newer canonical key. + # Detection mirrors the gateway resolver: root-level config and the + # canonical ``pinUserPeer`` both affect routing, so host-only reads would + # mis-classify a profile that inherits its mapping from root. ( current_pin, current_aliases, @@ -587,102 +678,109 @@ def cmd_setup(args) -> None: else: current_shape = "multi" - print("\n Deployment shape (how gateway users map to peers):") - print(" single -- all platforms route to your peer (recommended for personal use)") - print(" multi -- each platform user gets their own peer (multi-user bots)") - print(" hybrid -- multi-user, but YOUR runtime IDs alias to your peer") - print(" skip -- don't touch identity-mapping config") - new_shape = _prompt("Deployment shape", default=current_shape).strip().lower() - - # Transitioning single → multi orphans the peerName pool for runtime users - # (their resolved peers go from peerName to runtime-derived IDs with empty - # history). Steer the operator toward hybrid so their own continuity is - # preserved via alias mappings. - if current_shape == "single" and new_shape == "multi": - peer_target = hermes_host.get("peerName") or current_peer or "user" - print( - f"\n ⚠ Switching from single to multi will orphan memory accumulated\n" - f" under peer '{peer_target}'. Existing runtime users (Telegram,\n" - f" Discord, etc.) will resolve to fresh, empty peers." - ) - print(" To keep your own continuity, choose 'hybrid' and alias your\n" - " runtime IDs back to peerName.") - confirm = _prompt("Continue with multi anyway? (yes/hybrid/no)", default="hybrid").strip().lower() - if confirm in {"hybrid", "h"}: - new_shape = "hybrid" - elif confirm not in {"yes", "y"}: - new_shape = "skip" - - # Each shape branch scrubs every peer-mapping key before writing its own, - # so a stale alias/prefix/pin from an earlier run starts clean. - if new_shape == "single": - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = True - print(f" pinUserPeer=true → all gateway users route to '{hermes_host.get('peerName', '?')}'.") - elif new_shape == "multi": - # Preserve operator-curated, host-level aliases so multi → multi - # re-runs don't drop them. Root-sourced aliases are left to - # cascade naturally and are NOT copied down into the host. - prior_aliases = ( - dict(current_aliases) - if isinstance(current_aliases, dict) and not aliases_from_root - else {} - ) - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - # Do NOT auto-write ``userPeerAliases: {}``: an empty host map - # would override any root-level ``userPeerAliases`` the operator - # set as a cross-host baseline, silently disabling those aliases. - # Absence is the right "no host opinion" signal. - if prior_aliases: - hermes_host["userPeerAliases"] = prior_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - # Only write a host-level prefix when the operator typed one that - # diverges from the inherited root value; otherwise let the root - # cascade continue unmodified. - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(" Multi-user mode: each runtime ID → own peer. Use 'hermes honcho status' to inspect.") - elif new_shape == "hybrid": - # Hybrid encodes operator intent at the host level: collect existing - # entries (host or root) so the wizard never silently drops a known - # alias, then write the combined map. Materialising root entries - # into the host is the right move here — once the operator answers - # the alias prompts for a host, they're declaring "this host owns - # the mapping". - existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} - _scrub_identity_mapping(hermes_host) - hermes_host["pinUserPeer"] = False - peer_target = hermes_host.get("peerName") or current_peer or "user" - print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") - print(" Leave blank to skip a platform. Existing aliases are preserved.") - for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), - ("Discord snowflake", "e.g. 491827364"), - ("Slack user ID", "e.g. U04ABCDEF"), - ("Matrix MXID", "e.g. @you:matrix.org"), - ): - entered = _prompt(f" {platform_label} ({alias_hint})", default="").strip() - if entered: - existing_aliases[entered] = peer_target - if existing_aliases: - hermes_host["userPeerAliases"] = existing_aliases - _prefix_default = current_prefix or "" - _new_prefix = _prompt( - "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", - default=_prefix_default, - ).strip() - if _new_prefix and not (prefix_from_root and _new_prefix == current_prefix): - hermes_host["runtimePeerPrefix"] = _new_prefix - print(f" Hybrid mode: your runtime IDs → '{peer_target}', others → own peer.") - elif new_shape == "skip": - pass # leave config untouched + gw_platforms = _gateway_platforms() + if gw_platforms is None: + print("\n Gateway identity mapping routes platform users to memory peers.") + run_mapping = _prompt( + "Running the Hermes gateway (Telegram/Discord/etc.)? (y/N)", + default="n", + ).strip().lower() in {"y", "yes"} + elif not gw_platforms: + print("\n No gateway platforms connected — identity mapping only affects") + print(" gateway users, so this step doesn't apply here.") + run_mapping = _prompt( + "Configure gateway mapping anyway? (y/N)", default="n", + ).strip().lower() in {"y", "yes"} else: - print(f" Unknown shape '{new_shape}' — leaving identity-mapping config untouched.") + print(f"\n Gateway platforms detected: {', '.join(gw_platforms)}") + run_mapping = True + + if run_mapping: + peer_target = hermes_host.get("peerName") or current_peer or "user" + default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") + print("\n How should gateway users map to memory peers?") + print(" [1] just me — everyone collapses to your peer") + print(" [2] me + other people — keep mine pooled, others separate") + print(" [3] only other people — everyone gets their own peer") + print(" [s] skip (leave untouched) [e] edit raw keys") + choice = _prompt("Choice", default=default_choice).strip().lower() + + if choice in {"2", "me+others", "both"}: + pooled = _prompt( + " Keep my own memory pooled across platforms? (Y/n)", default="y", + ).strip().lower() + shape = "hybrid" if pooled in {"y", "yes", ""} else "multi" + elif choice in {"1", "me", "just-me"}: + shape = "single" + elif choice in {"3", "others"}: + shape = "multi" + elif choice in {"e", "edit", "raw"}: + shape = "raw" + else: + shape = "skip" + + # Un-pinning a currently-pinned profile without aliasing strands the + # pooled peerName history; steer the operator toward pooling instead. + if current_pin and shape == "multi": + print( + f"\n ⚠ Un-pinning will orphan memory accumulated under peer\n" + f" '{peer_target}'. Existing gateway users resolve to fresh,\n" + f" empty peers." + ) + confirm = _prompt( + " Pool my own memory instead (alias my IDs to peerName)? (Y/n)", + default="y", + ).strip().lower() + if confirm in {"y", "yes", ""}: + shape = "hybrid" + + # Each branch scrubs every peer-mapping key first so a stale alias, + # prefix, or pin from an earlier run starts clean. + if shape == "single": + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = True + print(f" All gateway users route to '{peer_target}'.") + _echo_identity_mapping(hermes_host) + elif shape == "multi": + # Preserve operator-curated host-level aliases across multi → multi + # re-runs. Root-sourced aliases cascade naturally and are NOT + # copied down — an empty host map would mask a root baseline. + prior_aliases = ( + dict(current_aliases) + if isinstance(current_aliases, dict) and not aliases_from_root + else {} + ) + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + if prior_aliases: + hermes_host["userPeerAliases"] = prior_aliases + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix (e.g. 'telegram_', blank for none)", + ) + print(" Each gateway user → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "hybrid": + existing_aliases = dict(current_aliases) if isinstance(current_aliases, dict) else {} + _scrub_identity_mapping(hermes_host) + hermes_host["pinUserPeer"] = False + merged = _collect_operator_aliases(existing_aliases, peer_target) + if merged: + hermes_host["userPeerAliases"] = merged + _apply_runtime_prefix( + hermes_host, current_prefix, prefix_from_root, + "Runtime peer prefix for unknown users (e.g. 'telegram_', blank for none)", + ) + print(f" Your runtime IDs → '{peer_target}', others → own peer.") + _echo_identity_mapping(hermes_host) + elif shape == "raw": + _configure_raw_identity_mapping( + hermes_host, current_pin, current_aliases, current_prefix, + aliases_from_root, prefix_from_root, + ) + _echo_identity_mapping(hermes_host) + else: # skip + print(" Identity mapping left untouched.") # --- 4. Observation mode --- current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index fcbce52703..afcc7af077 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -323,19 +323,20 @@ class TestCloneHonchoForProfile: class TestSetupWizardDeploymentShape: - """The deployment-shape step writes pinPeerName / userPeerAliases / - runtimePeerPrefix based on the operator's chosen shape. + """The gateway identity-mapping tree writes pinUserPeer / userPeerAliases / + runtimePeerPrefix based on the operator's intent. - Single-operator deployments collapse all platforms to peerName. - Multi-user gateways leave the resolver to route per-runtime. - Hybrid deployments alias the operator's own runtime IDs only. + Choice [1] (just me) collapses all platforms to peerName. + Choice [3] (only other people) leaves the resolver to route per-runtime. + Choice [2] (me + others, pooled) aliases the operator's own runtime IDs. - These tests script the interactive _prompt calls and assert the - resulting hermes_host block, so the wizard's deployment-shape + These tests mock gateway detection and script the interactive _prompt + calls, asserting the resulting hermes_host block so the tree's routing semantics stay locked even as adjacent prompts are added. """ - def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None): + def _run_setup(self, monkeypatch, tmp_path, *, answers, initial_cfg=None, + gateway_platforms=("telegram",)): import plugins.memory.honcho.cli as honcho_cli cfg_path = tmp_path / "config.json" @@ -348,6 +349,10 @@ class TestSetupWizardDeploymentShape: monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes") monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True) monkeypatch.setattr(honcho_cli, "_write_config", lambda *a, **k: None) + # Gate detection is mocked so tests control whether the tree runs. + # None → undetectable; list (possibly empty) → connected platforms. + gw = None if gateway_platforms is None else list(gateway_platforms) + monkeypatch.setattr(honcho_cli, "_gateway_platforms", lambda: gw) # Bypass config.yaml + connection test side effects. monkeypatch.setattr( @@ -393,14 +398,14 @@ class TestSetupWizardDeploymentShape: honcho_cli.cmd_setup(SimpleNamespace()) return cfg["hosts"]["hermes"] - def test_single_shape_sets_pin_peer_name_and_clears_aliases(self, monkeypatch, tmp_path): + def test_just_me_pins_and_clears_aliases(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "single", # deployment shape ← key answer + "1", # tree: just me ← key answer # remaining prompts fall through to defaults ] initial_cfg = { @@ -415,14 +420,14 @@ class TestSetupWizardDeploymentShape: assert "userPeerAliases" not in host assert "runtimePeerPrefix" not in host - def test_multi_shape_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): + def test_only_others_leaves_pin_false_and_accepts_prefix(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape + "3", # tree: only other people "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers) @@ -433,14 +438,15 @@ class TestSetupWizardDeploymentShape: assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" - def test_hybrid_shape_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): + def test_pooled_aliases_operator_runtime_ids_to_peer_name(self, monkeypatch, tmp_path): answers = [ "cloud", # deployment "", # api key (keep) "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "hybrid", # deployment shape + "2", # tree: me + other people + "y", # keep my memory pooled? → hybrid "86701400", # telegram uid "491827364", # discord snowflake "", # slack (skip) @@ -467,7 +473,7 @@ class TestSetupWizardDeploymentShape: }}, } answers = [ - "cloud", "", "eri", "hermetika", "hermes", "skip", + "cloud", "", "eri", "hermetika", "hermes", "s", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True @@ -475,10 +481,10 @@ class TestSetupWizardDeploymentShape: assert host["userPeerAliases"] == {"keep": "me"} assert host["runtimePeerPrefix"] == "keep_" - def test_single_to_multi_steers_to_hybrid_by_default(self, monkeypatch, tmp_path): - """Flipping single → multi triggers a warning that auto-steers the - operator to ``hybrid`` (default), so their own runtime IDs keep - landing on peerName instead of orphaning the pinned-pool history. + def test_unpin_steers_to_pooled_by_default(self, monkeypatch, tmp_path): + """Choosing 'only other people' on a currently-pinned profile triggers + the orphan warning, which auto-steers to pooled (hybrid) so the + operator's own runtime IDs keep landing on peerName. """ initial_cfg = { "apiKey": "***", @@ -490,8 +496,8 @@ class TestSetupWizardDeploymentShape: "eri", # peer name "hermetika", # ai peer "hermes", # workspace - "multi", # deployment shape — triggers the guard - "hybrid", # guard response: accept the steer + "3", # tree: only others — triggers the orphan guard + "y", # pool my own memory instead? → hybrid "86701400", # telegram uid "", # discord (skip) "", # slack (skip) @@ -502,42 +508,40 @@ class TestSetupWizardDeploymentShape: assert host["pinUserPeer"] is False assert host["userPeerAliases"] == {"86701400": "eri"} - def test_single_to_multi_yes_override_keeps_multi(self, monkeypatch, tmp_path): - """Operator can override the steer by answering ``yes`` and accept - the orphaning consequences. This is the explicit undo-the-pin path. - """ + def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): + """Operator can decline the steer ('n') and accept orphaning, ending + up with per-user peers (no aliases).""" initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinPeerName": True, "peerName": "eri"}}, } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # deployment shape — triggers the guard - "yes", # guard response: confirm multi + "3", # tree: only others — triggers the orphan guard + "n", # decline pooling, accept orphaning "telegram_", # runtime peer prefix ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - # See test_multi_shape_leaves_pin_false_and_accepts_prefix. assert "userPeerAliases" not in host assert host["runtimePeerPrefix"] == "telegram_" def test_host_pin_user_peer_true_is_detected_as_single(self, monkeypatch, tmp_path): """Host-level ``pinUserPeer: true`` must classify as ``single``. - Pressing Enter at the shape prompt then preserves the pin instead - of falling through to ``multi`` and orphaning the user's memory - pool — the bug the wizard regressed when ``pinUserPeer`` landed - as a higher-precedence alias. + Pressing Enter at the choice prompt then preserves the pin instead + of falling through to per-user routing and orphaning the user's + memory pool — the bug the wizard regressed when ``pinUserPeer`` + landed as a higher-precedence alias. """ initial_cfg = { "apiKey": "***", "hosts": {"hermes": {"pinUserPeer": True, "peerName": "eri"}}, } - # Exhaust the iterator before the shape prompt so the scripted - # mock falls through to the prompt's default (which is the - # wizard-detected shape). Scripting an explicit "" would NOT - # exercise that fallthrough — the mock returns it literally. + # Exhaust the iterator before the choice prompt so the scripted + # mock falls through to the prompt's default (the detected shape → + # choice "1"). Scripting an explicit "" would NOT exercise that + # fallthrough — the mock returns it literally. answers = ["cloud", "", "eri", "hermetika", "hermes"] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) # Scrub-then-write normalises onto the canonical pinUserPeer. @@ -581,16 +585,16 @@ class TestSetupWizardDeploymentShape: # operator edits live on the host block they're inspecting. assert host["userPeerAliases"] == {"86701400": "eri"} - def test_multi_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): - """Explicit ``multi`` must leave the host ``userPeerAliases`` key - absent, preserving any root-level aliases as a cross-host baseline. + def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): + """Explicitly choosing 'only other people' must leave the host + ``userPeerAliases`` key absent, preserving any root-level aliases as a + cross-host baseline. - Picking ``multi`` here is an active choice — detection would have - defaulted to ``hybrid`` because root aliases exist — so the - operator's intent is to drop the alias mapping for this host. - We honor that by writing ``pinUserPeer: false`` only, and rely - on the host's absence of ``userPeerAliases`` to inherit root. - That inheritance is intentional: a true wipe would require the + Picking [3] here is an active choice — detection would have defaulted + to [2]/hybrid because root aliases exist — so the operator's intent is + to drop the alias mapping for this host. We honor that by writing + ``pinUserPeer: false`` only, relying on the host's absence of + ``userPeerAliases`` to inherit root. A true wipe would require the operator to delete the root key explicitly. """ initial_cfg = { @@ -600,14 +604,14 @@ class TestSetupWizardDeploymentShape: } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "multi", # explicit multi override of detected hybrid + "3", # explicit per-user override of detected hybrid ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False assert "userPeerAliases" not in host - def test_single_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): - """Choosing ``single`` must overwrite a stale ``pinUserPeer: false`` + def test_just_me_scrubs_stale_pin_user_peer_false(self, monkeypatch, tmp_path): + """Choosing 'just me' must overwrite a stale ``pinUserPeer: false`` with ``pinUserPeer: true`` so the profile ends up genuinely pinned. """ initial_cfg = { @@ -619,11 +623,57 @@ class TestSetupWizardDeploymentShape: } answers = [ "cloud", "", "eri", "hermetika", "hermes", - "single", + "1", ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is True + def test_no_gateway_connected_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """With no gateway platforms connected, the tree is gated off; declining + the 'configure anyway?' prompt leaves identity mapping untouched.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=[], + ) + assert "pinUserPeer" not in host + assert "userPeerAliases" not in host + assert "runtimePeerPrefix" not in host + + def test_undetectable_gateway_skips_mapping_when_declined(self, monkeypatch, tmp_path): + """When the gateway package can't be inspected (None), the wizard asks + whether the gateway is running; 'no' skips the mapping step.""" + initial_cfg = { + "apiKey": "***", + "hosts": {"hermes": {"peerName": "eri"}}, + } + answers = ["cloud", "", "eri", "hermetika", "hermes", "n"] + host = self._run_setup( + monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg, + gateway_platforms=None, + ) + assert "pinUserPeer" not in host + + def test_raw_edit_sets_resolver_knobs_directly(self, monkeypatch, tmp_path): + """The [e] escape hatch lets a power user set pinUserPeer + an alias + + prefix directly, bypassing the intent tree.""" + answers = [ + "cloud", "", "eri", "hermetika", "hermes", + "e", # tree: edit raw keys + "false", # pinUserPeer + "99887766=eri", # one alias pair + "", # finish aliases + "discord_", # runtimePeerPrefix + ] + host = self._run_setup(monkeypatch, tmp_path, answers=answers) + assert host["pinUserPeer"] is False + assert host["userPeerAliases"] == {"99887766": "eri"} + assert host["runtimePeerPrefix"] == "discord_" + class TestCloneCarriesPinUserPeer: """``pinUserPeer`` (canonical name for ``pinPeerName``) must survive a From 99feb036077a2d6dc99e12d1902d05d28e13eb0a Mon Sep 17 00:00:00 2001 From: Erosika Date: Wed, 10 Jun 2026 16:15:17 -0400 Subject: [PATCH 03/92] docs(honcho): demote pinPeerName to deprecated alias; document gateway identity tree Drop pinPeerName from the key table (now a deprecated-alias note), and replace the single/multi/hybrid 'deployment shapes' section with the gateway-gated intent tree the wizard actually presents, including the [e] raw-edit hatch and the un-pin pooling steer. --- plugins/memory/honcho/README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 3774747d05..77270ffd2d 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -137,11 +137,12 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| -| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer. Also accepted as `pinPeerName` | -| `pinPeerName` | bool | `false` | Alias for `pinUserPeer`; same effect | +| `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | | `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | | `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | +> **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. + **Resolver ladder** (first match wins): ``` @@ -158,13 +159,15 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Host vs root semantics.** All three keys are accepted at both root and `hosts.` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`. -**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these): +**Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: -- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc. -- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans. -- **Hybrid** — `pinUserPeer: false`, `userPeerAliases` mapping the operator's runtime IDs to `peerName`. Multi-user gateway where YOU are routed but others stay distinct. +- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. +- **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. -**Migrating single → multi.** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, use the **hybrid** shape — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The setup wizard offers this path automatically when it detects a single → multi transition. +Pick **[e]** at the prompt to set the three keys directly instead of going through the tree. + +**Un-pinning (single → per-user).** Flipping `pinUserPeer` from `true` to `false` does not migrate data. Memory accumulated under `peerName` while pinned stays there; runtime users now resolve to fresh, empty peers. To preserve your own continuity, choose the **pooled** path — alias your runtime IDs back to `peerName` so your turns keep landing on the pooled history while other users get their own peers. The wizard offers this steer automatically when it detects you're un-pinning a previously pinned profile. ### Memory & Recall From 23a7458acfbea42e6c8ddf88bdba5c06152fe42c Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 14:58:19 -0400 Subject: [PATCH 04/92] docs(website): cover gateway identity mapping in Honcho feature page The identity-mapping keys never made it to the site docs. Add the three keys to the config reference and a Gateway Identity Mapping section: when it applies (gateway only, setup-gated), the intent tree, resolver order, the un-pin orphan warning, and the deprecated pinPeerName alias. --- website/docs/user-guide/features/honcho.md | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b971bea272..b2493de7f5 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -129,6 +129,9 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `messageMaxChars` | `25000` | Max chars per message sent via `add_messages()`. Chunked if exceeded | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. @@ -154,6 +157,30 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`). +## Gateway Identity Mapping + +These settings only matter when you run the [Hermes gateway](../../developer-guide/gateway-internals.md) — the one entrypoint where users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user). CLI, TUI, and desktop sessions have no runtime ID and always resolve to `peerName`, so off-gateway these keys do nothing. + +The setup wizard detects whether a gateway platform is connected and skips this step entirely if not. When it runs, it asks one question — *who talks to this gateway?* — and derives the keys: + +| Answer | Result | +|--------|--------| +| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | +| **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | + +Pick `[e]` at the prompt to set the three keys directly instead. + +The resolver tries the keys top-down, first match wins: `pinUserPeer` → `userPeerAliases[id]` → `runtimePeerPrefix + id` → raw runtime ID → `peerName` → session-key fallback. + +:::warning Un-pinning orphans pooled memory +Flipping `pinUserPeer` from `true` to `false` does not migrate data — memory accumulated under `peerName` stays there, and platform users resolve to fresh, empty peers. To keep your own continuity, choose the **pooled** path so your runtime IDs alias back to `peerName`. The wizard offers this steer automatically when it detects the transition. +::: + +:::note Deprecated key +`pinPeerName` is a legacy alias for `pinUserPeer` — still read for back-compat (`pinUserPeer` wins where both are set), never written. Re-running setup migrates it onto the canonical key. +::: + ## Observation (Directional vs. Unified) Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`: From 2708c33c7570d5d3d53c19c80124b06d0d939c08 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:04:01 -0400 Subject: [PATCH 05/92] docs(honcho): anonymize example peer name to alice --- plugins/memory/honcho/README.md | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 77270ffd2d..44c523be8b 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,7 +138,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | -| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "eri"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | | `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. @@ -208,7 +208,7 @@ The Honcho session name determines which conversation bucket memory lands in. Re Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions. -If `sessionPeerPrefix` is `true`, the peer name is prepended: `eri-hermes-agent`. +If `sessionPeerPrefix` is `true`, the peer name is prepended: `alice-hermes-agent`. #### What each strategy produces diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index b2493de7f5..4e8caa43a9 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,7 +130,7 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "eri"}`). Many-to-one | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | | `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: From 1544813bfe5658c3b8b9c5e5506ef3692b5fb567 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 11 Jun 2026 15:06:07 -0400 Subject: [PATCH 06/92] chore(honcho): replace example Telegram UID with placeholder --- plugins/memory/honcho/README.md | 4 +- plugins/memory/honcho/cli.py | 2 +- tests/gateway/test_agent_cache.py | 10 +- tests/honcho_plugin/test_cli.py | 16 +-- tests/honcho_plugin/test_pin_peer_name.py | 120 ++++++++++----------- website/docs/user-guide/features/honcho.md | 4 +- 6 files changed, 78 insertions(+), 78 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 44c523be8b..70fe1fb531 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -138,8 +138,8 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a | Key | Type | Default | Description | |-----|------|---------|-------------| | `pinUserPeer` | bool | `false` | When `true`, every gateway runtime user collapses to `peerName`. Single-operator deployments where you want all your platforms (and any other users) to share one peer | -| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"86701400": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | -| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_86701400`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | +| `userPeerAliases` | object | `{}` | Map of runtime IDs to peer IDs (`{"7654321": "alice"}`). Many-to-one is the intended pattern — alias all your runtime IDs to one peer name. One-to-many is not supported; one runtime ID resolves to exactly one peer | +| `runtimePeerPrefix` | string | `""` | Prepended to unknown runtime IDs to namespace them (e.g. `"telegram_"` → `telegram_7654321`). Used only when no alias matches. Prevents collisions between platforms whose runtime IDs share the same shape | > **Deprecated:** `pinPeerName` is a legacy alias for `pinUserPeer`, still read for back-compat (`pinUserPeer` wins where both are set). `hermes honcho setup` migrates it onto `pinUserPeer` on touch and never writes it. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 33edcf12dc..25460989df 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -413,7 +413,7 @@ def _collect_operator_aliases(existing: dict, peer_target: str) -> dict: print(f"\n Add runtime IDs that should alias to peer '{peer_target}'.") print(" Leave blank to skip a platform. Existing aliases are preserved.") for platform_label, alias_hint in ( - ("Telegram UID", "e.g. 86701400"), + ("Telegram UID", "e.g. 7654321"), ("Discord snowflake", "e.g. 491827364"), ("Slack user ID", "e.g. U04ABCDEF"), ("Matrix MXID", "e.g. @you:matrix.org"), diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 37f8b51a45..e3e14c7051 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -1466,7 +1466,7 @@ class TestAgentConfigSignatureUserId: from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="491827364" @@ -1477,10 +1477,10 @@ class TestAgentConfigSignatureUserId: from gateway.run import GatewayRunner runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_1 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) sig_2 = GatewayRunner._agent_config_signature( - "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="86701400" + "claude-sonnet-4", runtime, ["hermes-telegram"], "", user_id="7654321" ) assert sig_1 == sig_2 @@ -1489,11 +1489,11 @@ class TestAgentConfigSignatureUserId: runtime = {"provider": "anthropic", "api_key": "k", "base_url": "", "api_mode": "chat_completions"} sig_a = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@igor_tg", + user_id="7654321", user_id_alt="@igor_tg", ) sig_b = GatewayRunner._agent_config_signature( "claude-sonnet-4", runtime, ["hermes-telegram"], "", - user_id="86701400", user_id_alt="@erosika_tg", + user_id="7654321", user_id_alt="@erosika_tg", ) assert sig_a != sig_b diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index afcc7af077..c021cdb8cf 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -263,7 +263,7 @@ class TestCloneHonchoForProfile: "apiKey": "***", "hosts": { "hermes": { - "userPeerAliases": {"86701400": "eri", "discord-491827364": "eri"}, + "userPeerAliases": {"7654321": "eri", "discord-491827364": "eri"}, "peerName": "eri", }, }, @@ -272,7 +272,7 @@ class TestCloneHonchoForProfile: ok = honcho_cli.clone_honcho_for_profile("coder") assert ok is True new_block = written["cfg"]["hosts"]["hermes_coder"] - assert new_block["userPeerAliases"] == {"86701400": "eri", "discord-491827364": "eri"} + assert new_block["userPeerAliases"] == {"7654321": "eri", "discord-491827364": "eri"} def test_runtime_peer_prefix_carries_into_cloned_profile(self, monkeypatch, tmp_path): cfg = { @@ -447,7 +447,7 @@ class TestSetupWizardDeploymentShape: "hermes", # workspace "2", # tree: me + other people "y", # keep my memory pooled? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "491827364", # discord snowflake "", # slack (skip) "", # matrix (skip) @@ -456,7 +456,7 @@ class TestSetupWizardDeploymentShape: host = self._run_setup(monkeypatch, tmp_path, answers=answers) assert host["pinUserPeer"] is False assert host["userPeerAliases"] == { - "86701400": "eri", + "7654321": "eri", "491827364": "eri", } assert "runtimePeerPrefix" not in host @@ -498,7 +498,7 @@ class TestSetupWizardDeploymentShape: "hermes", # workspace "3", # tree: only others — triggers the orphan guard "y", # pool my own memory instead? → hybrid - "86701400", # telegram uid + "7654321", # telegram uid "", # discord (skip) "", # slack (skip) "", # matrix (skip) @@ -506,7 +506,7 @@ class TestSetupWizardDeploymentShape: ] host = self._run_setup(monkeypatch, tmp_path, answers=answers, initial_cfg=initial_cfg) assert host["pinUserPeer"] is False - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_unpin_decline_steer_keeps_per_user(self, monkeypatch, tmp_path): """Operator can decline the steer ('n') and accept orphaning, ending @@ -575,7 +575,7 @@ class TestSetupWizardDeploymentShape: """ initial_cfg = { "apiKey": "***", - "userPeerAliases": {"86701400": "eri"}, + "userPeerAliases": {"7654321": "eri"}, "hosts": {"hermes": {"peerName": "eri"}}, } answers = ["cloud", "", "eri", "hermetika", "hermes"] @@ -583,7 +583,7 @@ class TestSetupWizardDeploymentShape: assert host["pinUserPeer"] is False # Hybrid materialises the root aliases into the host so subsequent # operator edits live on the host block they're inspecting. - assert host["userPeerAliases"] == {"86701400": "eri"} + assert host["userPeerAliases"] == {"7654321": "eri"} def test_only_others_does_not_override_root_user_peer_aliases(self, monkeypatch, tmp_path): """Explicitly choosing 'only other people' must leave the host diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index 1e72bc97d1..1a6e2394a8 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -105,7 +105,7 @@ class TestRuntimePeerMappingConfigParsing: config_file.write_text(json.dumps({ "apiKey": "k", "userPeerAliases": { - " 86701400 ": " Igor ", + " 7654321 ": " Igor ", "": "ignored", "empty-value": " ", "null-value": None, @@ -115,7 +115,7 @@ class TestRuntimePeerMappingConfigParsing: config = HonchoClientConfig.from_global_config(config_path=config_file) - assert config.user_peer_aliases == {"86701400": "Igor"} + assert config.user_peer_aliases == {"7654321": "Igor"} assert config.runtime_peer_prefix == "telegram_" def test_host_aliases_override_root_aliases_as_whole_map(self, tmp_path): @@ -226,12 +226,12 @@ class TestPeerResolutionOrder: mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name="Igor", pin_peer_name=False), - runtime_user_peer_name="86701400", # e.g. Telegram UID + runtime_user_peer_name="7654321", # e.g. Telegram UID ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400", ( + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321", ( "pin_peer_name=False is the multi-user default — the gateway's " "platform-native user ID must win so each user gets their own " "peer scope. If this regresses, every Telegram/Discord/Slack " @@ -245,14 +245,14 @@ class TestPeerResolutionOrder: config=self._config( peer_name="Igor", pin_peer_name=False, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_unknown_runtime_id_uses_prefix(self): @@ -264,12 +264,12 @@ class TestPeerResolutionOrder: pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" def test_prefixed_runtime_id_hashes_when_sanitization_is_lossy(self): """Generated prefixed IDs avoid merges caused by lossy sanitization.""" @@ -291,43 +291,43 @@ class TestPeerResolutionOrder: def test_prefixed_runtime_id_hashes_when_it_collides_with_peer_name(self): """Unknown generated peers should not silently merge into peerName.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( - peer_name="telegram_86701400", + peer_name="telegram_7654321", pin_peer_name=False, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_hashes_when_it_collides_with_alias_target(self): """Unknown generated peers should not silently merge into alias targets.""" - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" expected_hash = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest()[:8] mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"known-user": "telegram_86701400"}, + user_peer_aliases={"known-user": "telegram_7654321"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{expected_hash}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{expected_hash}" def test_prefixed_runtime_id_extends_hash_when_short_hash_collides(self): - raw_peer_id = "telegram_86701400" + raw_peer_id = "telegram_7654321" digest = hashlib.sha256(raw_peer_id.encode("utf-8")).hexdigest() mgr = HonchoSessionManager( honcho=MagicMock(), @@ -335,17 +335,17 @@ class TestPeerResolutionOrder: peer_name=None, pin_peer_name=False, user_peer_aliases={ - "known-user": "telegram_86701400", - "reserved-user": f"telegram_86701400-{digest[:8]}", + "known-user": "telegram_7654321", + "reserved-user": f"telegram_7654321-{digest[:8]}", }, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == f"telegram_86701400-{digest[:12]}" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == f"telegram_7654321-{digest[:12]}" def test_alias_value_is_sanitized_after_selection(self): mgr = HonchoSessionManager( @@ -353,13 +353,13 @@ class TestPeerResolutionOrder: config=self._config( peer_name=None, pin_peer_name=False, - user_peer_aliases={"86701400": "Alice Smith!"}, + user_peer_aliases={"7654321": "Alice Smith!"}, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Alice-Smith-" def test_alias_keys_match_raw_runtime_id_before_sanitization(self): @@ -391,13 +391,13 @@ class TestPeerResolutionOrder: runtime_peer_prefix="telegram_", session_peer_prefix=True, ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "telegram_86701400" - assert session.honcho_session_id == "telegram-86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "telegram_7654321" + assert session.honcho_session_id == "telegram-7654321" def test_config_wins_when_pin_is_true(self): """With pin enabled, configured peer_name beats runtime ID.""" @@ -406,14 +406,14 @@ class TestPeerResolutionOrder: config=self._config( peer_name="Igor", pin_peer_name=True, - user_peer_aliases={"86701400": "Alias"}, + user_peer_aliases={"7654321": "Alias"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", # Telegram pushes this in + runtime_user_peer_name="7654321", # Telegram pushes this in ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor", ( "With pinPeerName=true the user's configured peer_name must " "beat the platform-native runtime ID so memory stays unified " @@ -429,26 +429,26 @@ class TestPeerResolutionOrder: config=self._config( peer_name=None, pin_peer_name=True, - user_peer_aliases={"86701400": "Igor"}, + user_peer_aliases={"7654321": "Igor"}, runtime_peer_prefix="telegram_", ), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" def test_pin_noop_without_peer_name_or_mapping_preserves_runtime(self): mgr = HonchoSessionManager( honcho=MagicMock(), config=self._config(peer_name=None, pin_peer_name=True), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") - assert session.user_peer_id == "86701400" + session = mgr.get_or_create("telegram:7654321") + assert session.user_peer_id == "7654321" def test_alt_runtime_id_can_match_alias_without_changing_raw_fallback(self): """Stable alternate IDs can map known users while primary ID fallback stays unchanged.""" @@ -526,11 +526,11 @@ class TestPeerResolutionOrder: mgr = HonchoSessionManager( honcho=MagicMock(), config=cfg, - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - session = mgr.get_or_create("telegram:86701400") + session = mgr.get_or_create("telegram:7654321") assert session.user_peer_id == "Igor" assert session.assistant_peer_id == "hermes-assistant" @@ -556,10 +556,10 @@ class TestCrossPlatformMemoryUnification: mgr_telegram = HonchoSessionManager( honcho=MagicMock(), config=self._config_pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_telegram) - telegram_session = mgr_telegram.get_or_create("telegram:86701400") + telegram_session = mgr_telegram.get_or_create("telegram:7654321") # Discord turn (separate manager instance — simulates a fresh # platform-adapter invocation) @@ -701,20 +701,20 @@ class TestPinTransition: pinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(pinned_mgr) - before = pinned_mgr.get_or_create("telegram:86701400") + before = pinned_mgr.get_or_create("telegram:7654321") assert before.user_peer_id == "Igor" unpinned_mgr = HonchoSessionManager( honcho=MagicMock(), config=self._unpinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(unpinned_mgr) - after = unpinned_mgr.get_or_create("telegram:86701400") - assert after.user_peer_id == "86701400", ( + after = unpinned_mgr.get_or_create("telegram:7654321") + assert after.user_peer_id == "7654321", ( "After flipping pinPeerName off, the same runtime ID must resolve " "to its own peer — otherwise multi-user mode silently merges users." ) @@ -723,14 +723,14 @@ class TestPinTransition: mgr = HonchoSessionManager( honcho=MagicMock(), config=self._pinned(), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr) - first = mgr.get_or_create("telegram:86701400") + first = mgr.get_or_create("telegram:7654321") assert first.user_peer_id == "Igor" mgr._config = self._unpinned() - second = mgr.get_or_create("telegram:86701400") + second = mgr.get_or_create("telegram:7654321") assert second.user_peer_id == "Igor", ( "The per-key session cache is keyed by session-key, not by " "resolved peer. In-process flips don't invalidate it — the " @@ -764,7 +764,7 @@ class TestPinTransition: cfg_path.write_text(json.dumps({ "apiKey": "k", "peerName": "Igor", - "userPeerAliases": {"86701400": "Igor"}, + "userPeerAliases": {"7654321": "Igor"}, })) sig_with_aliases = GatewayRunner._extract_cache_busting_config({"memory": {"provider": "honcho"}}) @@ -839,18 +839,18 @@ class TestProfilePeerUniqueness: mgr_a = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("alice"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_a) - sess_a = mgr_a.get_or_create("telegram:86701400") + sess_a = mgr_a.get_or_create("telegram:7654321") mgr_b = HonchoSessionManager( honcho=MagicMock(), config=self._pinned_to("bob"), - runtime_user_peer_name="86701400", + runtime_user_peer_name="7654321", ) _patch_manager_for_resolution_test(mgr_b) - sess_b = mgr_b.get_or_create("telegram:86701400") + sess_b = mgr_b.get_or_create("telegram:7654321") assert sess_a.user_peer_id == "alice" assert sess_b.user_peer_id == "bob" diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 4e8caa43a9..a692b26d96 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -130,8 +130,8 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, or `global` | | `pinUserPeer` | `false` | Gateway only. When `true`, every platform user collapses to `peerName` | -| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"86701400": "alice"}`). Many-to-one | -| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_86701400`) when no alias matches | +| `userPeerAliases` | `{}` | Gateway only. Map of runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | **Session strategy** controls how Honcho sessions map to your work: - `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users. From 5e851bc6bc5161960548d7ee72899a199a971ad7 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:01:28 +0700 Subject: [PATCH 07/92] fix(discord): cap slash commands at Discord's 100-command limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord enforces a hard cap of 100 global application commands per app. The adapter registers ~27 native commands plus every gateway-available entry in COMMAND_REGISTRY plus all plugin commands plus the consolidated /skill group. On a loaded install (many plugins/quick commands) the desired set exceeds 100, so tree.sync() / _safe_sync_slash_commands() hits error 30032 ("Maximum number of application commands reached") and Discord rejects the ENTIRE batch — silently breaking every slash command, not just the overflow. Cap registration at the 100-command limit: native commands (registered first, highest priority) and the /skill group are always kept; lower- priority auto-registered COMMAND_REGISTRY and plugin commands are added only until the cap is reached, with a single concise warning telling the user how to surface the rest. Since both sync paths read from tree.get_commands(), bounding the tree fixes the root cause for both. --- plugins/platforms/discord/adapter.py | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 196564dd14..26daab02c2 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -31,6 +31,12 @@ _DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway" _DISCORD_COMMAND_SYNC_STATE_FILENAME = "discord_command_sync_state.json" _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 +# Discord enforces a hard cap of 100 global application (slash) commands per +# app. Registering more makes the ENTIRE sync fail with error 30032 +# ("Maximum number of application commands reached"), which silently breaks +# every slash command — not just the overflow ones. We keep the desired set +# at or below this limit at registration time. +_DISCORD_MAX_APP_COMMANDS = 100 try: import discord @@ -3518,6 +3524,11 @@ class DiscordAdapter(BasePlatformAdapter): ) already_registered: set[str] = set() + # Native commands above are registered first and are the highest + # priority, so they always survive the 100-command cap. Reserve one + # slot for the consolidated ``/skill`` group registered further below. + slot_cap = _DISCORD_MAX_APP_COMMANDS - 1 + dropped_over_cap = 0 try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates @@ -3535,6 +3546,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = cmd_def.name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( cmd_def.name, cmd_def.description, @@ -3567,6 +3581,9 @@ class DiscordAdapter(BasePlatformAdapter): discord_name = plugin_name.lower()[:32] if discord_name in already_registered: continue + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + continue auto_cmd = _build_auto_slash_command( plugin_name, plugin_desc, @@ -3589,6 +3606,20 @@ class DiscordAdapter(BasePlatformAdapter): # supporting up to 25 categories × 25 skills = 625 skills. self._register_skill_group(tree) + if dropped_over_cap: + # Staying under the cap keeps the whole sync succeeding; without + # this guard a single over-limit command makes Discord reject the + # entire batch (error 30032), breaking every slash command. + logger.warning( + "[%s] Reached Discord's limit of %d slash commands; skipped %d " + "lower-priority command(s) to keep the command sync working. " + "Disable slash commands you don't need or trim installed plugins " + "to surface them all.", + self.name, + _DISCORD_MAX_APP_COMMANDS, + dropped_over_cap, + ) + # Optional defense-in-depth: hide every slash command from non-admin # guild members in Discord's slash picker. Server-side authorization # (``_check_slash_authorization``) is the actual gate; this is purely From 8f4a718f957d5d8fdb6264552ef46bb1c2ce4047 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:02:21 +0700 Subject: [PATCH 08/92] test(discord): guard slash-command registration against the 100 cap Registers 200 plugin commands on top of the native + COMMAND_REGISTRY set and asserts the tree never exceeds Discord's 100-command limit, that native high-priority commands survive the cap, and that overflow is actually dropped. Regression guard for the recurring error 30032 ("Maximum number of application commands reached") sync failures. --- tests/gateway/test_discord_slash_commands.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 8d44f77302..5ef6812f53 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -292,6 +292,58 @@ async def test_plugin_command_name_conflict_skipped(adapter): ) +# ------------------------------------------------------------------ +# 100-command cap (Discord error 30032 guard) +# ------------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_slash_command_registration_stays_under_discord_limit(adapter): + """Registering far more commands than Discord allows must NOT push the + tree over the 100-command hard cap. + + Discord rejects the ENTIRE command sync with error 30032 once the + desired set exceeds 100 global application commands, silently breaking + every slash command. The adapter must bound the desired set instead. + Regression guard for samuraiheart's recurring + "Maximum number of application commands reached (100)" sync failures. + """ + from plugins.platforms.discord.adapter import _DISCORD_MAX_APP_COMMANDS + + adapter._run_simple_slash = AsyncMock() + + # 200 plugin commands — way past Discord's limit on their own. + many_plugins = { + f"plug{i:03d}": { + "handler": lambda _a: "ok", + "description": f"Plugin command {i}", + "args_hint": "", + "plugin": "stress-plugin", + } + for i in range(200) + } + + with patch("hermes_cli.plugins.get_plugin_commands", return_value=many_plugins): + adapter._register_slash_commands() + + tree_names = set(adapter._client.tree.commands.keys()) + + # Contract: never exceed Discord's hard cap. + assert len(tree_names) <= _DISCORD_MAX_APP_COMMANDS, ( + f"registered {len(tree_names)} commands — exceeds Discord's " + f"{_DISCORD_MAX_APP_COMMANDS} limit and would fail sync with 30032" + ) + + # Native, high-priority commands are registered first and must survive + # the cap — they are the core UX, not droppable overflow. + for native in ("status", "stop", "new", "model", "help"): + assert native in tree_names, f"/{native} (native) was dropped by the cap" + + # The cap must actually have dropped overflow — not every plugin fit. + registered_plugins = [n for n in tree_names if n.startswith("plug")] + assert len(registered_plugins) < 200, "cap did not drop any overflow commands" + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ From a4ee1f223d5f3c0cae13a98c7214daefc2836145 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:20 +0700 Subject: [PATCH 09/92] fix(install): make `npm install -g` packages reachable on PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the installer falls back to a bundled Node under $HERMES_HOME/node, npm's default global prefix is that Node dir, so `npm install -g ` drops the package binary in $HERMES_HOME/node/bin. Only node/npm/npx are symlinked into the command link dir (~/.local/bin, /usr/local/bin, or $PREFIX/bin) — so user-installed global package binaries are NOT on PATH and can't be run, even though `npm i -g` reports success. They also get wiped on every Node upgrade (the dir is rm -rf'd and re-extracted). Redirect the bundled Node's npm global prefix to the command link dir's parent, so global bins land in the link dir (already on PATH, alongside node/npm/npx) and survive Node upgrades. Scoped to the bundled Node via its prefix-local global npmrc ($HERMES_HOME/node/etc/npmrc), so the user's other Node installs and their ~/.npmrc are untouched. Hermes's own global installs (agent-browser) pass an explicit --prefix and are unaffected. --- scripts/install.sh | 12 ++++++++++++ scripts/lib/node-bootstrap.sh | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index 7d644fe2d7..030d57d4c1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -851,6 +851,18 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" + # Point this Node's `npm install -g` at a directory that is actually on + # PATH. By default npm's global prefix is the Node install dir, so user + # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the + # link dir is) and is wiped on every Node upgrade. Redirecting the prefix + # to the link dir's parent makes global bins land in the link dir + # (node/npm/npx live there too, and it's already on PATH) and survive + # upgrades. Scoped to this Node via its prefix-local global npmrc, so the + # user's other Node installs and their ~/.npmrc are untouched. Hermes's + # own global installs pass an explicit --prefix and are unaffected. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" local installed_ver diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 02e568733f..15763d7048 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -206,6 +206,14 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/node" "$_link_dir/node" ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" + + # Redirect this Node's `npm install -g` to the link dir (already on PATH) + # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped + # on every Node upgrade. Scoped to this Node via its prefix-local global + # npmrc; the user's other Node installs / ~/.npmrc are untouched. + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + export PATH="$HERMES_HOME/node/bin:$PATH" _nb_have_modern_node || return 1 From 98205da008601525a658a106edb3c26199beb2b7 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:21:25 +0700 Subject: [PATCH 10/92] test(install): cover bundled-Node npm global prefix redirect Guards that install.sh and node-bootstrap.sh redirect the bundled Node's npm global prefix to the command link dir's parent via a prefix-local global npmrc, so `npm install -g` binaries land on PATH instead of the off-PATH $HERMES_HOME/node/bin. --- tests/test_install_sh_node_global_prefix.py | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/test_install_sh_node_global_prefix.py diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py new file mode 100644 index 0000000000..f604fc97d7 --- /dev/null +++ b/tests/test_install_sh_node_global_prefix.py @@ -0,0 +1,39 @@ +"""Regression tests for the Hermes-managed Node's npm global prefix. + +When the installer falls back to a bundled Node under ``$HERMES_HOME/node``, +npm's default global prefix is that Node dir, so ``npm install -g `` +drops the package binary in ``$HERMES_HOME/node/bin`` — which is NOT on PATH +(only the command link dir is) and is wiped on every Node upgrade. Users then +report "I can ``npm i -g`` but the package isn't usable on the command line". + +The fix redirects the bundled Node's global prefix to the command link dir's +parent (so global bins land in the already-on-PATH link dir alongside +node/npm/npx), scoped to the bundled Node via its prefix-local global npmrc. +""" + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" +NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + + +def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = INSTALL_SH.read_text() + + # The redirect must target the link dir's PARENT so global bins resolve to + # /bin == the command link dir (node/npm/npx live there and it is + # guaranteed on PATH by the installer's PATH setup). + assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + + # The npmrc lives under the bundled Node so it only affects this npm, not + # the user's other Node installs or their ~/.npmrc. + assert '"$HERMES_HOME/node/etc/npmrc"' in text + + +def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: + text = NODE_BOOTSTRAP.read_text() + + assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert '"$HERMES_HOME/node/etc/npmrc"' in text From 1db8f7ea8094d35ee9afdf81c6bd3b41d2fced1b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Sun, 14 Jun 2026 17:34:11 +0700 Subject: [PATCH 11/92] fix(install): repair existing managed-Node global prefix on re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix only wrote the prefix npmrc on a fresh Node install, so pre-existing bundled-Node installs (Node already present) were not repaired by re-running the installer — install_node/ensure_node skip when Node is already up to date. Extract the redirect into an idempotent helper (configure_managed_node_npm_prefix / _nb_configure_npm_prefix) that no-ops when there's no Hermes-managed npm, and call it unconditionally from check_node (install.sh) and at the top of ensure_node (node-bootstrap.sh). Re-running the install command now repairs an affected install in place, not just brand-new ones. --- scripts/install.sh | 36 ++++++++++++++------- scripts/lib/node-bootstrap.sh | 24 ++++++++++---- tests/test_install_sh_node_global_prefix.py | 26 ++++++++++++--- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 030d57d4c1..b3b5f104e3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -413,6 +413,25 @@ get_command_link_display_dir() { fi } +# Point a Hermes-managed Node's `npm install -g` at a directory that is on +# PATH. npm's default global prefix for a bundled Node is the Node dir itself, +# so global package binaries land in $HERMES_HOME/node/bin — which is NOT on +# PATH (only the command link dir is) and is wiped on every Node upgrade. +# Redirecting the prefix to the link dir's parent makes global bins resolve to +# the command link dir (node/npm/npx live there too, already on PATH) and +# survive upgrades. Scoped to the managed Node via its prefix-local global +# npmrc, so the user's other Node installs and their ~/.npmrc are untouched. +# Hermes's own global installs pass an explicit --prefix and are unaffected. +# Idempotent and a no-op when there is no Hermes-managed npm, so calling it on +# every install run repairs pre-existing installs, not just fresh ones. +configure_managed_node_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local link_dir + link_dir="$(get_command_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + get_hermes_command_path() { local link_dir link_dir="$(get_command_link_dir)" @@ -722,6 +741,11 @@ node_satisfies_build() { check_node() { log_info "Checking Node.js (for browser tools)..." + # Repair pre-existing Hermes-managed installs where `npm install -g` lands + # off PATH. No-op when there's no managed Node, so this is safe to run on + # every install — including re-runs that skip the Node (re)install below. + configure_managed_node_npm_prefix + if command -v node &> /dev/null && node_satisfies_build "$(node --version)"; then log_success "Node.js $(node --version) found" HAS_NODE=true @@ -851,17 +875,7 @@ install_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx" - # Point this Node's `npm install -g` at a directory that is actually on - # PATH. By default npm's global prefix is the Node install dir, so user - # globals land in $HERMES_HOME/node/bin — which is NOT on PATH (only the - # link dir is) and is wiped on every Node upgrade. Redirecting the prefix - # to the link dir's parent makes global bins land in the link dir - # (node/npm/npx live there too, and it's already on PATH) and survive - # upgrades. Scoped to this Node via its prefix-local global npmrc, so the - # user's other Node installs and their ~/.npmrc are untouched. Hermes's - # own global installs pass an explicit --prefix and are unaffected. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + configure_managed_node_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh index 15763d7048..332ad81180 100644 --- a/scripts/lib/node-bootstrap.sh +++ b/scripts/lib/node-bootstrap.sh @@ -57,6 +57,19 @@ _nb_get_link_dir() { fi } +# Redirect a Hermes-managed Node's `npm install -g` to the command link dir +# (already on PATH) instead of the default $HERMES_HOME/node/bin, which is off +# PATH and wiped on every Node upgrade. Scoped to the managed Node via its +# prefix-local global npmrc; the user's other Node installs / ~/.npmrc are +# untouched. Idempotent no-op when there's no managed npm. +_nb_configure_npm_prefix() { + [ -x "$HERMES_HOME/node/bin/npm" ] || return 0 + local _link_dir + _link_dir="$(_nb_get_link_dir)" + mkdir -p "$HERMES_HOME/node/etc" + printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" +} + _nb_node_major() { local v v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) @@ -207,12 +220,7 @@ _nb_install_bundled_node() { ln -sf "$HERMES_HOME/node/bin/npm" "$_link_dir/npm" ln -sf "$HERMES_HOME/node/bin/npx" "$_link_dir/npx" - # Redirect this Node's `npm install -g` to the link dir (already on PATH) - # instead of the default $HERMES_HOME/node/bin, which is off PATH and wiped - # on every Node upgrade. Scoped to this Node via its prefix-local global - # npmrc; the user's other Node installs / ~/.npmrc are untouched. - mkdir -p "$HERMES_HOME/node/etc" - printf 'prefix=%s\n' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc" + _nb_configure_npm_prefix export PATH="$HERMES_HOME/node/bin:$PATH" @@ -228,6 +236,10 @@ _nb_install_bundled_node() { ensure_node() { HERMES_NODE_AVAILABLE=false + # Repair pre-existing managed installs where `npm install -g` lands off + # PATH. No-op when there's no managed Node, so it's safe to run first. + _nb_configure_npm_prefix + if _nb_have_modern_node; then _nb_ok "Node $(node --version) found" HERMES_NODE_AVAILABLE=true diff --git a/tests/test_install_sh_node_global_prefix.py b/tests/test_install_sh_node_global_prefix.py index f604fc97d7..e43b9201bd 100644 --- a/tests/test_install_sh_node_global_prefix.py +++ b/tests/test_install_sh_node_global_prefix.py @@ -25,15 +25,31 @@ def test_install_sh_redirects_bundled_npm_global_prefix_to_link_dir() -> None: # The redirect must target the link dir's PARENT so global bins resolve to # /bin == the command link dir (node/npm/npx live there and it is # guaranteed on PATH by the installer's PATH setup). - assert 'printf \'prefix=%s\\n\' "$(dirname "$node_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text + assert "configure_managed_node_npm_prefix()" in text + assert 'printf \'prefix=%s\\n\' "$(dirname "$link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - # The npmrc lives under the bundled Node so it only affects this npm, not - # the user's other Node installs or their ~/.npmrc. - assert '"$HERMES_HOME/node/etc/npmrc"' in text + +def test_install_sh_repairs_existing_managed_node_on_rerun() -> None: + """The redirect must run on every install (not just fresh Node installs), + so re-running the installer repairs pre-existing managed installs whose + Node is already up to date and would otherwise skip install_node.""" + text = INSTALL_SH.read_text() + + check_node_body = text.split("check_node()", 1)[1].split("\ninstall_node()", 1)[0] + assert "configure_managed_node_npm_prefix" in check_node_body + + # No-op guard so it's safe to call when there is no managed Node. + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text def test_node_bootstrap_redirects_bundled_npm_global_prefix_to_link_dir() -> None: text = NODE_BOOTSTRAP.read_text() + assert "_nb_configure_npm_prefix()" in text assert 'printf \'prefix=%s\\n\' "$(dirname "$_link_dir")" > "$HERMES_HOME/node/etc/npmrc"' in text - assert '"$HERMES_HOME/node/etc/npmrc"' in text + + # Runs at the top of ensure_node so existing managed installs are repaired + # even when a modern Node is already present (early return path). + ensure_node_body = text.split("ensure_node()", 1)[1] + assert "_nb_configure_npm_prefix" in ensure_node_body + assert '[ -x "$HERMES_HOME/node/bin/npm" ] || return 0' in text From aca11c227eb7e8b2f53f6e130d6e922455a573c1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 03:51:48 -0700 Subject: [PATCH 12/92] fix(docker): skip gateway reconciliation in dashboard container (autodetect) (#46293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): skip per-profile gateway reconciliation in dashboard container When gateway and dashboard containers share a bind-mounted HERMES_HOME, both run the cont-init.d profile reconciliation script, which creates s6-log processes for every persisted profile. These s6-log processes in different containers race to flock() the same log-directory lock files under logs/gateways//lock, producing repeated "s6-log: fatal: unable to lock ... Resource busy" errors and a supervision restart storm. Add HERMES_SKIP_PROFILE_RECONCILE env var support to container_boot.py and set it in the official docker-compose.yml dashboard service so the dashboard container no longer creates per-profile gateway s6 services it never uses. * chore(release): map salvaged contributor * refactor(docker): autodetect dashboard container instead of env-var gate Replace the HERMES_SKIP_PROFILE_RECONCILE env var with PID 1 argv role detection. A dashboard-only container never spawns or supervises per-profile gateways, so the reconcile boot hook now skips itself when /proc/1/cmdline is the dashboard command — no operator flag to set (or forget in a hand-written manifest, which would reintroduce the s6-log flock storm this prevents). - Extract _strip_container_argv_prefix() shared by the legacy-gateway and new dashboard detectors (DRY the init/wrapper/hermes peel). - Add _is_dashboard_container(); gate reconcile main() on it. - Drop HERMES_SKIP_PROFILE_RECONCILE from code + docker-compose.yml. - Tests: argv matrix for both roles + main()-level skip/reconcile proof and a regression that the removed env var is now inert. Co-authored-by: 895252509 <895252509@qq.com> --------- Co-authored-by: zhouxiang <895252509@qq.com> Co-authored-by: Ben --- hermes_cli/container_boot.py | 54 ++++++++- scripts/release.py | 1 + tests/hermes_cli/test_container_boot.py | 141 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) diff --git a/hermes_cli/container_boot.py b/hermes_cli/container_boot.py index d1a7ccd7d4..647545dd5d 100644 --- a/hermes_cli/container_boot.py +++ b/hermes_cli/container_boot.py @@ -207,8 +207,15 @@ def _read_container_argv() -> tuple[str, ...]: return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part) -def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: - """Return True for Docker commands equivalent to `gateway run`.""" +def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]: + """Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args. + + The container PID 1 argv looks like + ``/init /opt/hermes/docker/main-wrapper.sh [args...]`` and + the wrapper re-execs ``hermes ``. Peel ``init`` → + ``main-wrapper.sh`` → ``hermes`` so callers can match on the bare + subcommand. Shared by the legacy-gateway and dashboard role detectors. + """ args = list(argv) if args and Path(args[0]).name == "init": args = args[1:] @@ -216,11 +223,38 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: args = args[1:] if args and Path(args[0]).name == "hermes": args = args[1:] + return args + + +def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool: + """Return True for Docker commands equivalent to `gateway run`.""" + args = _strip_container_argv_prefix(argv) if "--no-supervise" in args: return False return len(args) >= 2 and args[0] == "gateway" and args[1] == "run" +def _is_dashboard_container(argv: Sequence[str]) -> bool: + """Return True when the container's command is the dashboard. + + A dashboard-only container (``hermes dashboard ...``) never spawns or + supervises per-profile gateways — that is the gateway container's job. + Reconciling profile gateway s6 slots there is not just wasted work: when + the gateway and dashboard containers share a bind-mounted HERMES_HOME, + both race to ``flock()`` the same ``logs/gateways//lock`` files, + producing "Resource busy" failures and an s6-log restart storm. So the + dashboard container skips reconciliation entirely. + + Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator + flag: the role is a fact about the container's command, not a tunable, + and a flag can be forgotten in a hand-written compose/k8s manifest — + reintroducing the exact storm this prevents. Mirrors the argv handling + in :func:`_is_legacy_gateway_run_request`. + """ + args = _strip_container_argv_prefix(argv) + return bool(args) and args[0] == "dashboard" + + def _read_desired_state(profile_dir: Path) -> str | None: """Read the persisted gateway desired state for reconciliation. @@ -393,6 +427,22 @@ _LOG_ROTATE_BYTES = 256 * 1024 def main() -> int: """Entry point invoked from /etc/cont-init.d/02-reconcile-profiles.""" + # A dashboard-only container never spawns or supervises per-profile + # gateways, so reconciling their s6 slots here is pure waste — and + # actively harmful: when the gateway and dashboard containers share a + # bind-mounted HERMES_HOME, both race to flock() the same s6-log lock + # files under logs/gateways//lock, producing "Resource busy" + # failures and a restart storm. Detect the role from PID 1 argv and + # skip reconciliation in the dashboard container. No operator flag: + # the role is a fact about the container's command, and a flag can be + # forgotten in a hand-written manifest, reintroducing the storm. + if _is_dashboard_container(_read_container_argv()): + print( + "reconcile: skipping (dashboard container — does not need " + "per-profile gateways)" + ) + return 0 + hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data")) scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service")) actions = reconcile_profile_gateways( diff --git a/scripts/release.py b/scripts/release.py index 63fb97a49c..c5b3597733 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -84,6 +84,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", "tangtaizhong792@gmail.com": "tangtaizong666", diff --git a/tests/hermes_cli/test_container_boot.py b/tests/hermes_cli/test_container_boot.py index db43ff90f1..a86321a688 100644 --- a/tests/hermes_cli/test_container_boot.py +++ b/tests/hermes_cli/test_container_boot.py @@ -708,3 +708,144 @@ def test_profiles_default_subdir_is_skipped_with_warning( assert any( "profiles/default/" in record.message for record in caplog.records ) + + +# --------------------------------------------------------------------------- +# Dashboard-container role detection (skip reconcile on the dashboard) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "container_argv", + [ + # Bare subcommand (docker run ... dashboard ...). + ("dashboard",), + ("dashboard", "--host", "127.0.0.1", "--no-open"), + # Through s6 /init + the main-wrapper that re-execs `hermes`. + ("/init", "/opt/hermes/docker/main-wrapper.sh", "dashboard"), + ( + "/init", + "/opt/hermes/docker/main-wrapper.sh", + "dashboard", + "--host", + "127.0.0.1", + "--no-open", + ), + # Wrapper that kept the explicit `hermes` argv0. + ("/init", "/opt/hermes/docker/main-wrapper.sh", "hermes", "dashboard"), + ], +) +def test_is_dashboard_container_true_for_dashboard_argv( + container_argv: tuple[str, ...], +) -> None: + """A dashboard command is detected across every wrapper prefix shape.""" + from hermes_cli.container_boot import _is_dashboard_container + + assert _is_dashboard_container(container_argv) is True + + +@pytest.mark.parametrize( + "container_argv", + [ + (), # empty (/proc/1/cmdline unreadable) — not the dashboard + ("gateway", "run"), + ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ("/init", "/opt/hermes/docker/main-wrapper.sh", "hermes", "gateway", "run"), + ("chat",), + # A profile literally named "dashboard" must NOT match — the token + # we key on is the SUBCOMMAND, and `gateway run -p dashboard` is a + # gateway container. + ("gateway", "run", "-p", "dashboard"), + ], +) +def test_is_dashboard_container_false_for_non_dashboard_argv( + container_argv: tuple[str, ...], +) -> None: + """Gateway / other commands (and empty argv) are not the dashboard.""" + from hermes_cli.container_boot import _is_dashboard_container + + assert _is_dashboard_container(container_argv) is False + + +def test_main_skips_reconcile_in_dashboard_container( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """main() must NOT reconcile when PID 1 argv is the dashboard command. + + A running profile is seeded so that, if reconcile ran, it would create + the gateway- slot. Asserting the slot is absent proves the + skip is real, not just a log line. + """ + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "dashboard"), + ) + + rc = container_boot.main() + + assert rc == 0 + assert not (scandir / "gateway-worker").exists() + assert not (scandir / "gateway-default").exists() + assert "skipping (dashboard container" in capsys.readouterr().out + + +def test_main_reconciles_in_gateway_container( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """main() reconciles normally when PID 1 argv is the gateway command — + the dashboard skip is scoped strictly to the dashboard role.""" + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ) + + rc = container_boot.main() + + assert rc == 0 + # The worker slot was registered + started (prior_state running). + assert (scandir / "gateway-worker").exists() + assert not (scandir / "gateway-worker" / "down").exists() + + +def test_main_ignores_removed_skip_reconcile_env_var( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The legacy HERMES_SKIP_PROFILE_RECONCILE flag is gone: setting it on a + gateway container must NOT suppress reconciliation. Role is decided by + PID 1 argv alone, so a stale flag in someone's manifest is inert.""" + from hermes_cli import container_boot + + scandir = tmp_path / "run-service"; scandir.mkdir() + _make_profile(tmp_path, "worker", state="running") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("S6_PROFILE_GATEWAY_SCANDIR", str(scandir)) + monkeypatch.setenv("HERMES_SKIP_PROFILE_RECONCILE", "1") + monkeypatch.setattr( + container_boot, + "_read_container_argv", + lambda: ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"), + ) + + rc = container_boot.main() + + assert rc == 0 + # Reconcile still ran despite the stale env var. + assert (scandir / "gateway-worker").exists() From dcc32169552f6c04791531465c35879361fdba3b Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:19:48 -0600 Subject: [PATCH 13/92] fix(mcp): fail fast for noninteractive oauth without tokens --- tests/hermes_cli/test_mcp_config.py | 10 ++++++- tests/tools/test_mcp_oauth.py | 33 +++++++++++------------ tests/tools/test_mcp_oauth_integration.py | 10 +++++++ tests/tools/test_mcp_oauth_manager.py | 25 +++++++++++++++++ tools/mcp_oauth.py | 10 +++---- tools/mcp_oauth_manager.py | 12 +++++---- 6 files changed, 72 insertions(+), 28 deletions(-) diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index 5817241176..ec5f5eefe9 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -11,6 +11,14 @@ from pathlib import Path import pytest +def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None: + from unittest.mock import MagicMock + + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = is_tty + monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin) + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -649,6 +657,7 @@ class TestMcpRemoveEvictsManager: "hermes_cli.mcp_config.get_hermes_home", lambda: tmp_path ) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests reset_manager_for_tests() @@ -745,4 +754,3 @@ class TestMcpLogin: assert "Authenticated — 3 tool(s) available" in out assert "no OAuth token" not in out - diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index e43bf0a185..0f9987b7ce 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -26,6 +26,12 @@ from tools.mcp_oauth import ( ) +def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None: + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = is_tty + monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin) + + # --------------------------------------------------------------------------- # HermesTokenStorage # --------------------------------------------------------------------------- @@ -164,6 +170,7 @@ class TestBuildOAuthAuth: pytest.skip("MCP SDK auth not available") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) auth = build_oauth_auth("test", "https://example.com/mcp") assert isinstance(auth, OAuthClientProvider) @@ -180,6 +187,7 @@ class TestBuildOAuthAuth: pytest.skip("MCP SDK auth not available") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) build_oauth_auth("slack", "https://slack.example.com/mcp", { "client_id": "my-app-id", "client_secret": "my-secret", @@ -199,6 +207,7 @@ class TestBuildOAuthAuth: pytest.skip("MCP SDK auth not available") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) provider = build_oauth_auth("scoped", "https://example.com/mcp", { "scope": "read write admin", }) @@ -403,6 +412,7 @@ class TestOAuthPortSharing: pytest.skip("MCP SDK auth not available") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) build_oauth_auth("test-port", "https://example.com/mcp") assert mod._oauth_port is not None assert isinstance(mod._oauth_port, int) @@ -479,32 +489,21 @@ class TestWaitForCallbackNoBlocking: class TestBuildOAuthAuthNonInteractive: """build_oauth_auth() in non-interactive mode.""" - def test_noninteractive_without_cached_tokens_warns(self, tmp_path, monkeypatch, caplog): - """Without cached tokens, non-interactive mode logs a clear warning.""" - try: - from mcp.client.auth import OAuthClientProvider - except ImportError: - pytest.skip("MCP SDK auth not available") + def test_noninteractive_without_cached_tokens_fails_fast(self, tmp_path, monkeypatch): + """Without cached tokens, non-interactive mode skips browser auth.""" + pytest.importorskip("mcp.client.auth") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) mock_stdin = MagicMock() mock_stdin.isatty.return_value = False monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin) - import logging - with caplog.at_level(logging.WARNING, logger="tools.mcp_oauth"): - auth = build_oauth_auth("atlassian", "https://mcp.atlassian.com/v1/mcp") - - assert auth is not None - assert "no cached tokens found" in caplog.text.lower() - assert "non-interactive" in caplog.text.lower() + with pytest.raises(OAuthNonInteractiveError, match="non-interactive"): + build_oauth_auth("atlassian", "https://mcp.atlassian.com/v1/mcp") def test_noninteractive_with_cached_tokens_no_warning(self, tmp_path, monkeypatch, caplog): """With cached tokens, non-interactive mode logs no 'no cached tokens' warning.""" - try: - from mcp.client.auth import OAuthClientProvider - except ImportError: - pytest.skip("MCP SDK auth not available") + pytest.importorskip("mcp.client.auth") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) mock_stdin = MagicMock() diff --git a/tests/tools/test_mcp_oauth_integration.py b/tests/tools/test_mcp_oauth_integration.py index 9e80400246..2735aad022 100644 --- a/tests/tools/test_mcp_oauth_integration.py +++ b/tests/tools/test_mcp_oauth_integration.py @@ -18,6 +18,14 @@ import pytest pytest.importorskip("mcp.client.auth.oauth2", reason="MCP SDK 1.26.0+ required") +def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None: + from unittest.mock import MagicMock + + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = is_tty + monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin) + + @pytest.mark.asyncio async def test_external_refresh_picked_up_without_restart(tmp_path, monkeypatch): """Simulate Cthulhu's cron workflow end-to-end. @@ -160,6 +168,7 @@ async def test_handle_401_returns_false_when_no_provider(tmp_path, monkeypatch): async def test_invalidate_if_disk_changed_handles_missing_file(tmp_path, monkeypatch): """invalidate_if_disk_changed returns False when tokens file doesn't exist.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import MCPOAuthManager, reset_manager_for_tests reset_manager_for_tests() @@ -181,6 +190,7 @@ async def test_provider_is_reused_across_reconnects(tmp_path, monkeypatch): first post-reconnect auth flow would spuriously "detect" a change. """ monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import MCPOAuthManager, reset_manager_for_tests reset_manager_for_tests() diff --git a/tests/tools/test_mcp_oauth_manager.py b/tests/tools/test_mcp_oauth_manager.py index 2a66449cbd..7896fe6447 100644 --- a/tests/tools/test_mcp_oauth_manager.py +++ b/tests/tools/test_mcp_oauth_manager.py @@ -7,6 +7,7 @@ cache. See `tools/mcp_oauth_manager.py` for design rationale. import json import os import time +from unittest.mock import MagicMock import pytest @@ -16,6 +17,12 @@ pytest.importorskip( ) +def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None: + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = is_tty + monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin) + + def test_manager_is_singleton(): """get_manager() returns the same instance across calls.""" from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests @@ -28,6 +35,7 @@ def test_manager_is_singleton(): def test_manager_get_or_build_provider_caches(tmp_path, monkeypatch): """Calling get_or_build_provider twice with same name returns same provider.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import MCPOAuthManager mgr = MCPOAuthManager() @@ -39,6 +47,7 @@ def test_manager_get_or_build_provider_caches(tmp_path, monkeypatch): def test_manager_get_or_build_rebuilds_on_url_change(tmp_path, monkeypatch): """Changing the URL discards the cached provider.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import MCPOAuthManager mgr = MCPOAuthManager() @@ -50,6 +59,7 @@ def test_manager_get_or_build_rebuilds_on_url_change(tmp_path, monkeypatch): def test_manager_remove_evicts_cache(tmp_path, monkeypatch): """remove(name) evicts the provider from cache AND deletes disk files.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) from tools.mcp_oauth_manager import MCPOAuthManager # Pre-seed tokens on disk @@ -131,6 +141,7 @@ def test_manager_builds_hermes_provider_subclass(tmp_path, monkeypatch): ) reset_manager_for_tests() monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch) mgr = MCPOAuthManager() provider = mgr.get_or_build_provider("srv", "https://example.com/mcp", None) @@ -139,3 +150,17 @@ def test_manager_builds_hermes_provider_subclass(tmp_path, monkeypatch): assert isinstance(provider, _HERMES_PROVIDER_CLS) assert provider._hermes_server_name == "srv" + +def test_manager_fails_fast_noninteractive_without_cached_tokens(tmp_path, monkeypatch): + """A daemon without cached MCP OAuth tokens must not enter browser auth.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _set_interactive_stdin(monkeypatch, is_tty=False) + from tools.mcp_oauth import OAuthNonInteractiveError + from tools.mcp_oauth_manager import MCPOAuthManager + + mgr = MCPOAuthManager() + + with pytest.raises(OAuthNonInteractiveError, match="non-interactive"): + mgr.get_or_build_provider("linear", "https://mcp.linear.app/mcp", None) + + assert mgr._entries["linear"].provider is None diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index 832a6f5945..3f85f4859e 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -754,12 +754,12 @@ def build_oauth_auth( storage = HermesTokenStorage(server_name) if not _is_interactive() and not storage.has_cached_tokens(): - logger.warning( - "MCP OAuth for '%s': non-interactive environment and no cached tokens " + raise OAuthNonInteractiveError( + "MCP OAuth for " + f"'{server_name}': non-interactive environment and no cached tokens " "found. The OAuth flow requires browser authorization. Run " - "interactively first to complete the initial authorization, then " - "cached tokens will be reused.", - server_name, + f"`hermes mcp login {server_name}` interactively first to complete " + "initial authorization, then cached tokens will be reused." ) _configure_callback_port(cfg) diff --git a/tools/mcp_oauth_manager.py b/tools/mcp_oauth_manager.py index 6a4573a867..da9125d53c 100644 --- a/tools/mcp_oauth_manager.py +++ b/tools/mcp_oauth_manager.py @@ -408,6 +408,7 @@ class MCPOAuthManager: # Local imports avoid circular deps at module import time. from tools.mcp_oauth import ( HermesTokenStorage, + OAuthNonInteractiveError, _OAUTH_AVAILABLE, _build_client_metadata, _configure_callback_port, @@ -424,11 +425,12 @@ class MCPOAuthManager: storage = HermesTokenStorage(server_name) if not _is_interactive() and not storage.has_cached_tokens(): - logger.warning( - "MCP OAuth for '%s': non-interactive environment and no " - "cached tokens found. Run interactively first to complete " - "initial authorization.", - server_name, + raise OAuthNonInteractiveError( + "MCP OAuth for " + f"'{server_name}': non-interactive environment and no " + "cached tokens found. Run `hermes mcp login " + f"{server_name}` interactively first to complete initial " + "authorization." ) _configure_callback_port(cfg) From f1d6f0436224cbb7adfad23ed235d92cca26a62e Mon Sep 17 00:00:00 2001 From: Andrew Walker Date: Wed, 3 Jun 2026 14:27:50 -0500 Subject: [PATCH 14/92] fix(auth): resolve xAI OAuth credentials across profiles (cherry picked from commit 8d8b9f50e486fbb77e20799ce0b49d23ac853e88) --- hermes_cli/auth.py | 53 ++++++++++++- .../hermes_cli/test_xai_oauth_profile_auth.py | 79 +++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_xai_oauth_profile_auth.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0d5887ec9d..e3be09eee0 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3886,13 +3886,64 @@ def _pool_codex_access_token() -> str: # xAI Grok OAuth — tokens stored in ~/.hermes/auth.json # ============================================================================= +def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return usable xAI OAuth state from provider state or credential pool.""" + state = _load_provider_state(auth_store, "xai-oauth") + tokens = state.get("tokens") if isinstance(state, dict) else None + if isinstance(tokens, dict): + access_token = str(tokens.get("access_token", "") or "").strip() + refresh_token = str(tokens.get("refresh_token", "") or "").strip() + if access_token and refresh_token: + return state + + credential_pool = auth_store.get("credential_pool") + entries = ( + credential_pool.get("xai-oauth") + if isinstance(credential_pool, dict) + else None + ) + if isinstance(entries, list): + for entry in entries: + if not isinstance(entry, dict): + continue + access_token = str(entry.get("access_token", "") or "").strip() + refresh_token = str(entry.get("refresh_token", "") or "").strip() + if not access_token or not refresh_token: + continue + merged = dict(state or {}) + merged["tokens"] = { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": str(entry.get("token_type") or "Bearer"), + } + if entry.get("last_refresh"): + merged["last_refresh"] = entry.get("last_refresh") + merged.setdefault("auth_mode", "oauth_pkce") + return merged + + return state if isinstance(state, dict) else None + + +def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool: + tokens = state.get("tokens") if isinstance(state, dict) else None + return ( + isinstance(tokens, dict) + and bool(str(tokens.get("access_token", "") or "").strip()) + and bool(str(tokens.get("refresh_token", "") or "").strip()) + ) + + def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: if _lock: with _auth_store_lock(): auth_store = _load_auth_store() else: auth_store = _load_auth_store() - state = _load_provider_state(auth_store, "xai-oauth") + state = _xai_oauth_state_from_store(auth_store) + if not _xai_oauth_state_has_usable_tokens(state): + global_state = _xai_oauth_state_from_store(_load_global_auth_store()) + if _xai_oauth_state_has_usable_tokens(global_state): + state = global_state if not state: raise AuthError( "No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.", diff --git a/tests/hermes_cli/test_xai_oauth_profile_auth.py b/tests/hermes_cli/test_xai_oauth_profile_auth.py new file mode 100644 index 0000000000..836fc6e8a8 --- /dev/null +++ b/tests/hermes_cli/test_xai_oauth_profile_auth.py @@ -0,0 +1,79 @@ +"""Regression tests for xAI OAuth auth resolution in profile/cron contexts.""" + +import pytest + +from hermes_cli import auth +from hermes_cli.auth import AuthError + + +def test_read_xai_oauth_tokens_uses_credential_pool_when_provider_tokens_empty(monkeypatch): + """Profile auth can have fresh pool tokens while singleton provider state is empty. + + This mirrors profiled cron after re-auth/credential-pool sync: the xAI + OAuth credential is usable, but `providers.xai-oauth.tokens` may be empty + or stale. Treating that as missing auth makes cron keep failing after the + user has successfully re-authenticated. + """ + store = { + "providers": {"xai-oauth": {"tokens": {}, "last_auth_error": {}}}, + "credential_pool": { + "xai-oauth": [ + { + "access_token": "pool-access", + "refresh_token": "pool-refresh", + "token_type": "Bearer", + "last_refresh": "2026-06-03T19:00:00Z", + } + ] + }, + } + monkeypatch.setattr(auth, "_load_auth_store", lambda: store) + monkeypatch.setattr(auth, "_load_global_auth_store", lambda: {}) + + resolved = auth._read_xai_oauth_tokens(_lock=False) + + assert resolved["tokens"]["access_token"] == "pool-access" + assert resolved["tokens"]["refresh_token"] == "pool-refresh" + assert resolved["tokens"]["token_type"] == "Bearer" + assert resolved["last_refresh"] == "2026-06-03T19:00:00Z" + + +def test_read_xai_oauth_tokens_uses_global_store_when_profile_state_empty(monkeypatch): + """A profile/cron process should see root xAI auth after user re-auths there.""" + profile_store = {"providers": {"xai-oauth": {"tokens": {}}}} + global_store = { + "providers": { + "xai-oauth": { + "tokens": { + "access_token": "global-access", + "refresh_token": "global-refresh", + "token_type": "Bearer", + }, + "last_refresh": "2026-06-03T19:05:00Z", + } + } + } + monkeypatch.setattr(auth, "_load_auth_store", lambda: profile_store) + monkeypatch.setattr(auth, "_load_global_auth_store", lambda: global_store) + + resolved = auth._read_xai_oauth_tokens(_lock=False) + + assert resolved["tokens"]["access_token"] == "global-access" + assert resolved["tokens"]["refresh_token"] == "global-refresh" + assert resolved["last_refresh"] == "2026-06-03T19:05:00Z" + + +def test_read_xai_oauth_tokens_still_requires_usable_tokens(monkeypatch): + """Fallback should not hide genuinely broken xAI auth state.""" + store = { + "providers": {"xai-oauth": {"tokens": {}}}, + "credential_pool": {"xai-oauth": [{"access_token": "", "refresh_token": ""}]}, + } + monkeypatch.setattr(auth, "_load_auth_store", lambda: store) + monkeypatch.setattr(auth, "_load_global_auth_store", lambda: {}) + + with pytest.raises(AuthError) as exc: + auth._read_xai_oauth_tokens(_lock=False) + + assert exc.value.code == "xai_auth_missing_access_token" + assert exc.value.relogin_required is True From 497352bc4e53f824900ae76219d1e5c315b1a15f Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:08:19 +0530 Subject: [PATCH 15/92] fix(auth): write rotated xAI OAuth tokens back to global root (#43589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged read-side fix lets a profile resolve the xAI OAuth grant from the global-root auth store when it has no own providers.xai-oauth block. But _save_xai_oauth_tokens still wrote rotated tokens only to the active profile store. Because xAI rotates the refresh_token on every refresh, a profile that reads root's grant and refreshes it left root holding a now- revoked refresh token — killing every other profile reading the stale root grant with invalid_grant once its access token expired (#43589). Detect the read-from-root case (profile lacks its own providers.xai-oauth block) and, after the profile save, write the rotated chain back to the global root too via a best-effort, TOCTOU-safe write-through that reuses _save_auth_store with an explicit target path. A profile that genuinely shadows root (has its own block) is left untouched, classic mode is a no-op, and a failed root write never breaks the profile's own save. Pairs with the read fallback in the preceding commit so the cross-profile xAI grant stays coherent in both directions. --- hermes_cli/auth.py | 72 +++++++- .../hermes_cli/test_xai_oauth_writethrough.py | 169 ++++++++++++++++++ 2 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_xai_oauth_writethrough.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e3be09eee0..0950d225f7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1079,8 +1079,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: return {"version": AUTH_STORE_VERSION, "providers": {}} -def _save_auth_store(auth_store: Dict[str, Any]) -> Path: - auth_file = _auth_file_path() +def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path: + # target_path=None preserves the existing contract (write the active + # store at _auth_file_path()). An explicit path lets callers persist a + # specific store — e.g. the global-root write-through for rotating xAI + # OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600 + # write so the root auth.json gets the same TOCTOU-safe treatment. + auth_file = target_path if target_path is not None else _auth_file_path() auth_file.parent.mkdir(parents=True, exist_ok=True) # Tighten parent dir to 0o700 so siblings can't traverse to creds. # No-op on Windows (POSIX mode bits not enforced); ignore failures. @@ -3983,6 +3988,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]: } +def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool: + """True when this store has its OWN ``providers.xai-oauth`` block. + + Distinguishes a profile that genuinely shadows the root xAI grant from + one that only *reads* root via ``_load_provider_state``'s fallback. Only + the latter needs the refresh write-through below. + """ + providers = auth_store.get("providers") + return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict) + + +def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None: + """Persist a rotated xAI OAuth ``state`` into the global-root auth.json. + + Best-effort write-through for the multi-profile rotation hazard (#43589): + xAI rotates the refresh_token on every refresh, so when a profile session + refreshes a grant it resolved from the root fallback, the rotated chain + must land back in root. Otherwise root keeps a now-revoked refresh token + and every other profile reading the stale root grant dies with + ``invalid_grant`` once its access token expires. + + Only updates ``providers.xai-oauth`` in the root store; never touches the + profile store (the caller already saved that). Swallows all errors — a + failed write-through degrades to the pre-existing behavior (root stale), + it must never break the profile's own successful save. + """ + global_path = _global_auth_file_path() + if global_path is None: + # Classic mode (profile == root); the profile save already hit root. + return + # Seat belt: under pytest, refuse to write the real user's + # ~/.hermes/auth.json even when HERMES_HOME points at a profile path + # (mirrors the read-side guard in _load_global_auth_store). Uses the + # unmodified HOME env, not Path.home() which fixtures may monkeypatch. + if os.environ.get("PYTEST_CURRENT_TEST"): + real_home_env = os.environ.get("HOME", "") + if real_home_env: + real_root = Path(real_home_env) / ".hermes" / "auth.json" + try: + if global_path.resolve(strict=False) == real_root.resolve(strict=False): + return + except Exception: + return + try: + if global_path.exists(): + global_store = _load_auth_store(global_path) + else: + global_store = {} + if not isinstance(global_store, dict): + return + _store_provider_state(global_store, "xai-oauth", dict(state), set_active=False) + _save_auth_store(global_store, global_path) + except Exception as exc: # pragma: no cover - best effort + logger.debug("xAI OAuth: write-through to global root failed: %s", exc) + + def _save_xai_oauth_tokens( tokens: Dict[str, Any], *, @@ -3994,6 +4055,11 @@ def _save_xai_oauth_tokens( last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") with _auth_store_lock(): auth_store = _load_auth_store() + # A profile that lacks its own xai-oauth block is reading the root + # grant through _load_provider_state's fallback. When such a profile + # refreshes the (rotating) grant, we must write the rotated chain back + # to root too, or root is left holding a revoked refresh token (#43589). + write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store) state = _load_provider_state(auth_store, "xai-oauth") or {} state["tokens"] = tokens state["last_refresh"] = last_refresh @@ -4004,6 +4070,8 @@ def _save_xai_oauth_tokens( state["redirect_uri"] = redirect_uri _save_provider_state(auth_store, "xai-oauth", state) _save_auth_store(auth_store) + if write_through_to_root: + _write_through_xai_oauth_to_global_root(state) def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool: diff --git a/tests/hermes_cli/test_xai_oauth_writethrough.py b/tests/hermes_cli/test_xai_oauth_writethrough.py new file mode 100644 index 0000000000..d706c76d09 --- /dev/null +++ b/tests/hermes_cli/test_xai_oauth_writethrough.py @@ -0,0 +1,169 @@ +"""Regression tests for xAI OAuth refresh write-through to the global root. + +Companion to ``test_xai_oauth_profile_auth.py``. That file covers the READ +fallback (profile -> credential pool -> global root). These cover the WRITE +side: when a profile that has no own ``providers.xai-oauth`` block refreshes +the (rotating) grant it resolved from the root fallback, the rotated tokens +must be written back to the global root too. Otherwise root keeps a revoked +refresh token and every other profile reading root's stale grant dies with +``invalid_grant`` once its access token expires (issue #43589). + +The tests drive the real ``_save_xai_oauth_tokens`` against real on-disk auth +stores (profile + root under ``tmp_path``) rather than mocking the save +boundary, so they exercise the actual atomic write path. +""" + +import json + +import pytest + +from hermes_cli import auth + + +def _write_store(path, store): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store), encoding="utf-8") + + +def _read_store(path): + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.fixture +def profile_and_root(tmp_path, monkeypatch): + """Wire a profile auth store + a distinct global-root auth store on disk. + + Returns (profile_path, root_path). The pytest seat belt in + ``_write_through_xai_oauth_to_global_root`` only refuses the *real* user's + ``$HOME/.hermes/auth.json``; a tmp_path root is allowed, so we point HOME + away from the tmp root to keep the guard from tripping on these fixtures. + """ + profile_path = tmp_path / "profiles" / "work" / "auth.json" + root_path = tmp_path / "root" / "auth.json" + + monkeypatch.setattr(auth, "_auth_file_path", lambda: profile_path) + monkeypatch.setattr(auth, "_global_auth_file_path", lambda: root_path) + # Keep the pytest write seat belt from matching our tmp root. + monkeypatch.setenv("HOME", str(tmp_path / "not-the-root")) + return profile_path, root_path + + +def test_refresh_writes_through_to_root_when_profile_has_no_own_state(profile_and_root): + """Profile reading root's grant must push rotated tokens back to root.""" + profile_path, root_path = profile_and_root + # Profile has NO own xai-oauth block (reads root via fallback). + _write_store(profile_path, {"version": 1, "providers": {}}) + _write_store( + root_path, + { + "version": 1, + "providers": { + "xai-oauth": { + "tokens": { + "access_token": "old-access", + "refresh_token": "old-refresh", + } + } + }, + }, + ) + + rotated = { + "access_token": "new-access", + "refresh_token": "new-refresh", + "token_type": "Bearer", + } + auth._save_xai_oauth_tokens(rotated) + + # Profile got the rotated chain (existing behavior). + profile = _read_store(profile_path) + assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "new-refresh" + + # AND the global root no longer holds the revoked refresh token (#43589). + root = _read_store(root_path) + assert root["providers"]["xai-oauth"]["tokens"]["access_token"] == "new-access" + assert root["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "new-refresh" + + +def test_refresh_does_not_touch_root_when_profile_has_own_state(profile_and_root): + """A profile that genuinely shadows root must NOT clobber the root grant.""" + profile_path, root_path = profile_and_root + # Profile has its OWN xai-oauth block: it shadows root legitimately. + _write_store( + profile_path, + { + "version": 1, + "providers": { + "xai-oauth": { + "tokens": { + "access_token": "profile-old", + "refresh_token": "profile-old-refresh", + } + } + }, + }, + ) + _write_store( + root_path, + { + "version": 1, + "providers": { + "xai-oauth": { + "tokens": { + "access_token": "root-untouched", + "refresh_token": "root-untouched-refresh", + } + } + }, + }, + ) + + auth._save_xai_oauth_tokens( + {"access_token": "profile-new", "refresh_token": "profile-new-refresh"} + ) + + profile = _read_store(profile_path) + assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "profile-new-refresh" + + # Root is a separate grant chain — must be left exactly as-is. + root = _read_store(root_path) + assert root["providers"]["xai-oauth"]["tokens"]["access_token"] == "root-untouched" + assert root["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "root-untouched-refresh" + + +def test_write_through_is_noop_in_classic_mode(tmp_path, monkeypatch): + """Classic mode (profile == root) already saves to root; no double write.""" + profile_path = tmp_path / "auth.json" + monkeypatch.setattr(auth, "_auth_file_path", lambda: profile_path) + # Classic mode: _global_auth_file_path returns None. + monkeypatch.setattr(auth, "_global_auth_file_path", lambda: None) + _write_store(profile_path, {"version": 1, "providers": {}}) + + # Should not raise and should persist to the single store. + auth._save_xai_oauth_tokens( + {"access_token": "a", "refresh_token": "r"} + ) + store = _read_store(profile_path) + assert store["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "r" + + +def test_write_through_failure_does_not_break_profile_save(profile_and_root, monkeypatch): + """A failed root write-through must not break the profile's own save.""" + profile_path, root_path = profile_and_root + _write_store(profile_path, {"version": 1, "providers": {}}) + _write_store(root_path, {"version": 1, "providers": {}}) + + # Make the root write blow up; the profile save must still succeed. + real_save = auth._save_auth_store + + def _exploding_save(store, target_path=None): + if target_path is not None and target_path == root_path: + raise OSError("simulated root write failure") + return real_save(store, target_path) + + monkeypatch.setattr(auth, "_save_auth_store", _exploding_save) + + auth._save_xai_oauth_tokens({"access_token": "a", "refresh_token": "r"}) + + profile = _read_store(profile_path) + assert profile["providers"]["xai-oauth"]["tokens"]["refresh_token"] == "r" From 1227007aed1dfcdd33a9cec5dc969a1313caa8aa Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:09:27 +0530 Subject: [PATCH 16/92] chore: map capt-marbles contributor email for attribution Salvaged commit in this PR is authored by capt-marbles (andrewdmwalker@gmail.com), a bare gmail that does not auto-resolve in the check-attribution job. Add the AUTHOR_MAP entry. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index c5b3597733..dbf61be2e6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1541,6 +1541,7 @@ AUTHOR_MAP = { "desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages) "bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API) "sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456) + "andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589) } From a376ca00819e14f611ebc5e3ff2e207cd5563db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 15 Jun 2026 11:55:07 +0200 Subject: [PATCH 17/92] feat(hindsight): make observation scopes configurable on retain Adds an observation_scopes config key (and HINDSIGHT_RETAIN_OBSERVATION_SCOPES env var) so retained memories can opt into per_tag / all_combinations / custom scoping instead of Hindsight's default combined pass. Threaded through _build_retain_kwargs so all three retain paths honor it: auto-retain and flush-on-switch already use aretain_batch; the tool retain path is switched from aretain to aretain_batch (functionally equivalent, aretain just wraps a single-item batch) since aretain doesn't accept the observation_scopes parameter. --- plugins/memory/hindsight/__init__.py | 68 +++++++++++++- .../plugins/memory/test_hindsight_provider.py | 88 ++++++++++++++++--- 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index dd16f44920..c26e45a0e1 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -18,6 +18,7 @@ Config via environment variables: HINDSIGHT_TIMEOUT — API request timeout in seconds (default: 120) HINDSIGHT_IDLE_TIMEOUT — embedded daemon idle timeout seconds; 0 disables shutdown (default: 300) HINDSIGHT_RETAIN_TAGS — comma-separated tags attached to retained memories + HINDSIGHT_RETAIN_OBSERVATION_SCOPES — observation scoping for retained memories: per_tag/combined/all_combinations, or a JSON list of tag-lists for custom scopes HINDSIGHT_RETAIN_SOURCE — metadata source value attached to retained memories HINDSIGHT_RETAIN_USER_PREFIX — label used before user turns in retained transcripts HINDSIGHT_RETAIN_ASSISTANT_PREFIX — label used before assistant turns in retained transcripts @@ -326,6 +327,7 @@ def _load_config() -> dict: "timeout": _parse_int_setting(os.environ.get("HINDSIGHT_TIMEOUT"), _DEFAULT_TIMEOUT), "idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT), "retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""), + "observation_scopes": os.environ.get("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", ""), "retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""), "retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"), "retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"), @@ -376,6 +378,56 @@ def _normalize_retain_tags(value: Any) -> List[str]: return normalized +_OBSERVATION_SCOPE_KEYWORDS = {"per_tag", "combined", "all_combinations"} + + +def _normalize_observation_scopes(value: Any) -> Any: + """Normalize an observation_scopes config value to a Hindsight-accepted form. + + Returns one of: + * ``None`` — nothing configured; Hindsight applies its ``combined`` default. + * a keyword string — ``"per_tag"`` / ``"combined"`` / ``"all_combinations"``. + * ``list[list[str]]`` — custom scopes, one inner list per consolidation pass. + + Accepts a keyword string, a JSON-encoded list, a flat list of tags (treated as + a single scope), or a list of tag-lists. Anything unrecognized yields ``None`` + so we never send an invalid payload. + """ + if value is None: + return None + + if isinstance(value, str): + text = value.strip() + if not text: + return None + if text in _OBSERVATION_SCOPE_KEYWORDS: + return text + if text.startswith("["): + try: + parsed = json.loads(text) + except Exception: + return None + return _normalize_observation_scopes(parsed) + return None + + if isinstance(value, (list, tuple)): + # A flat list of tag strings is one scope; a list of lists is many. + if all(isinstance(entry, str) for entry in value): + inner = [entry.strip() for entry in value if entry.strip()] + return [inner] if inner else None + scopes: list[list[str]] = [] + for entry in value: + if isinstance(entry, (list, tuple)): + inner = [str(tag).strip() for tag in entry if str(tag).strip()] + if inner: + scopes.append(inner) + elif isinstance(entry, str) and entry.strip(): + scopes.append([entry.strip()]) + return scopes or None + + return None + + def _utc_timestamp() -> str: """Return current UTC timestamp in ISO-8601 with milliseconds and Z suffix.""" return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") @@ -865,6 +917,7 @@ class HindsightMemoryProvider(MemoryProvider): {"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]}, {"key": "recall_prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]}, {"key": "retain_tags", "description": "Default tags applied to retained memories (comma-separated)", "default": ""}, + {"key": "observation_scopes", "description": "How observations are scoped during consolidation: 'combined' (default — one pass over all tags), 'per_tag' (one isolated observation per tag), 'all_combinations' (every tag subset — expensive), or a JSON list of tag-lists for explicit custom scopes. Empty uses Hindsight's 'combined' default.", "default": ""}, {"key": "retain_source", "description": "Metadata source value attached to retained memories", "default": ""}, {"key": "retain_user_prefix", "description": "Label used before user turns in retained transcripts", "default": "User"}, {"key": "retain_assistant_prefix", "description": "Label used before assistant turns in retained transcripts", "default": "Assistant"}, @@ -1184,6 +1237,10 @@ class HindsightMemoryProvider(MemoryProvider): or os.environ.get("HINDSIGHT_RETAIN_TAGS", "") ) self._tags = self._retain_tags or None + self._observation_scopes = _normalize_observation_scopes( + self._config.get("observation_scopes") + or os.environ.get("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", "") + ) self._recall_tags = self._config.get("recall_tags") or None self._recall_tags_match = self._config.get("recall_tags_match", "any") self._retain_source = str( @@ -1438,6 +1495,8 @@ class HindsightMemoryProvider(MemoryProvider): merged_tags.append(tag) if merged_tags: kwargs["tags"] = merged_tags + if self._observation_scopes: + kwargs["observation_scopes"] = self._observation_scopes return kwargs def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None: @@ -1547,14 +1606,19 @@ class HindsightMemoryProvider(MemoryProvider): return tool_error("Missing required parameter: content") context = args.get("context") try: - retain_kwargs = self._build_retain_kwargs( + item = self._build_retain_kwargs( content, context=context, tags=args.get("tags"), ) + # aretain_batch takes bank_id/retain_async as call args, not item keys. + item.pop("bank_id", None) + item.pop("retain_async", None) logger.debug("Tool hindsight_retain: bank=%s, content_len=%d, context=%s", self._bank_id, len(content), context) - self._run_hindsight_operation(lambda client: client.aretain(**retain_kwargs)) + self._run_hindsight_operation( + lambda client: client.aretain_batch(bank_id=self._bank_id, items=[item]) + ) logger.debug("Tool hindsight_retain: success") return json.dumps({"result": "Memory stored successfully."}) except Exception as e: diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index a7ca66f73f..b121a2bb20 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -22,6 +22,7 @@ from plugins.memory.hindsight import ( RETAIN_SCHEMA, _load_config, _build_embedded_profile_env, + _normalize_observation_scopes, _normalize_retain_tags, _resolve_bank_id_template, _sanitize_bank_segment, @@ -40,7 +41,8 @@ def _clean_env(monkeypatch): "HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID", "HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_TIMEOUT", "HINDSIGHT_IDLE_TIMEOUT", "HINDSIGHT_LLM_API_KEY", - "HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_SOURCE", + "HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_OBSERVATION_SCOPES", + "HINDSIGHT_RETAIN_SOURCE", "HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX", ): monkeypatch.delenv(key, raising=False) @@ -153,6 +155,44 @@ def test_normalize_retain_tags_accepts_json_array_string(): assert _normalize_retain_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] +def test_normalize_observation_scopes_empty_is_none(): + assert _normalize_observation_scopes("") is None + assert _normalize_observation_scopes(None) is None + assert _normalize_observation_scopes(" ") is None + + +def test_normalize_observation_scopes_keywords_pass_through(): + assert _normalize_observation_scopes("per_tag") == "per_tag" + assert _normalize_observation_scopes("combined") == "combined" + assert _normalize_observation_scopes(" all_combinations ") == "all_combinations" + + +def test_normalize_observation_scopes_unknown_keyword_is_none(): + assert _normalize_observation_scopes("nonsense") is None + + +def test_normalize_observation_scopes_json_list_of_lists(): + value = json.dumps([["user:alice"], ["team:eng"], ["user:alice", "team:eng"]]) + assert _normalize_observation_scopes(value) == [ + ["user:alice"], + ["team:eng"], + ["user:alice", "team:eng"], + ] + + +def test_normalize_observation_scopes_flat_list_is_single_scope(): + assert _normalize_observation_scopes(["user:alice", "team:eng"]) == [ + ["user:alice", "team:eng"] + ] + + +def test_normalize_observation_scopes_list_of_lists(): + assert _normalize_observation_scopes([["user:alice"], ["team:eng"]]) == [ + ["user:alice"], + ["team:eng"], + ] + + # --------------------------------------------------------------------------- # Schema tests # --------------------------------------------------------------------------- @@ -198,6 +238,7 @@ class TestConfig: assert provider._recall_max_tokens == 4096 assert provider._recall_max_input_chars == 800 assert provider._tags is None + assert provider._observation_scopes is None assert provider._recall_tags is None # Default recall narrowed to observation-only; world/experience are # aggregate facts that often crowd out concrete-event signal during @@ -225,6 +266,16 @@ class TestConfig: p = provider_with_config(recall_types=[]) assert p._recall_types == ["observation"] + def test_observation_scopes_keyword_config(self, provider_with_config): + p = provider_with_config(observation_scopes="per_tag") + assert p._observation_scopes == "per_tag" + + def test_observation_scopes_custom_list_config(self, provider_with_config): + p = provider_with_config( + observation_scopes=[["user:alice"], ["team:eng"]] + ) + assert p._observation_scopes == [["user:alice"], ["team:eng"]] + def test_custom_config_values(self, provider_with_config): p = provider_with_config( retain_tags=["tag1", "tag2"], @@ -468,16 +519,20 @@ class TestToolHandlers: "hindsight_retain", {"content": "user likes dark mode"} )) assert result["result"] == "Memory stored successfully." - provider._client.aretain.assert_called_once() - call_kwargs = provider._client.aretain.call_args.kwargs + provider._client.aretain_batch.assert_called_once() + call_kwargs = provider._client.aretain_batch.call_args.kwargs assert call_kwargs["bank_id"] == "test-bank" - assert call_kwargs["content"] == "user likes dark mode" + item = call_kwargs["items"][0] + assert item["content"] == "user likes dark mode" + # bank_id/retain_async are call-level args, never item keys. + assert "bank_id" not in item + assert "retain_async" not in item def test_retain_with_tags(self, provider_with_config): p = provider_with_config(retain_tags=["pref", "ui"]) p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"}) - call_kwargs = p._client.aretain.call_args.kwargs - assert call_kwargs["tags"] == ["pref", "ui"] + item = p._client.aretain_batch.call_args.kwargs["items"][0] + assert item["tags"] == ["pref", "ui"] def test_retain_merges_per_call_tags_with_config_tags(self, provider_with_config): p = provider_with_config(retain_tags=["pref", "ui"]) @@ -485,13 +540,24 @@ class TestToolHandlers: "hindsight_retain", {"content": "likes dark mode", "tags": ["client:x", "ui"]}, ) - call_kwargs = p._client.aretain.call_args.kwargs - assert call_kwargs["tags"] == ["pref", "ui", "client:x"] + item = p._client.aretain_batch.call_args.kwargs["items"][0] + assert item["tags"] == ["pref", "ui", "client:x"] def test_retain_without_tags(self, provider): provider.handle_tool_call("hindsight_retain", {"content": "hello"}) - call_kwargs = provider._client.aretain.call_args.kwargs - assert "tags" not in call_kwargs + item = provider._client.aretain_batch.call_args.kwargs["items"][0] + assert "tags" not in item + + def test_retain_passes_observation_scopes(self, provider_with_config): + p = provider_with_config(observation_scopes="per_tag") + p.handle_tool_call("hindsight_retain", {"content": "likes dark mode"}) + item = p._client.aretain_batch.call_args.kwargs["items"][0] + assert item["observation_scopes"] == "per_tag" + + def test_retain_omits_observation_scopes_by_default(self, provider): + provider.handle_tool_call("hindsight_retain", {"content": "hello"}) + item = provider._client.aretain_batch.call_args.kwargs["items"][0] + assert "observation_scopes" not in item def test_retain_missing_content(self, provider): result = json.loads(provider.handle_tool_call( @@ -557,7 +623,7 @@ class TestToolHandlers: assert "error" in result def test_retain_error_handling(self, provider): - provider._client.aretain.side_effect = RuntimeError("connection failed") + provider._client.aretain_batch.side_effect = RuntimeError("connection failed") result = json.loads(provider.handle_tool_call( "hindsight_retain", {"content": "test"} )) From ec05d2bc3eb343968b9c2b1fc04b8195d48de40b Mon Sep 17 00:00:00 2001 From: Tharushka Dinujaya Date: Mon, 15 Jun 2026 16:48:14 +0530 Subject: [PATCH 18/92] fix(gateway): evict scoped lock when PID+start_time match but process is not a gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, systemd spawns core services (cron, nginx, sshd) with deterministic PIDs and jiffy start_times across reboots. A service can land on the exact same PID and start_time as a previous gateway, causing acquire_scoped_lock to mistake it for a live gateway and block startup. The existing stale-detection paths only covered: - start_times both non-None and different (clear mismatch) - start_times both None (macOS/Windows fallback to cmdline check) The boot-time collision falls through both: times are non-None and equal, so neither branch fired. Add a third check: when both start_times are known and match but the live process fails _looks_like_gateway_process, read its cmdline. If the cmdline is readable (non-None), we have positive evidence of an impostor and mark the lock stale. Requiring a readable cmdline keeps the check conservative — if cmdline is unreadable we do not evict. --- gateway/status.py | 15 +++++++++++++++ tests/gateway/test_status.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/gateway/status.py b/gateway/status.py index 8d2640af0f..a49999e712 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -643,6 +643,21 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str, live_cmdline = _read_process_cmdline(existing_pid) if live_cmdline is not None or not _record_looks_like_gateway(existing): stale = True + # Secondary defence against boot-time PID+start_time collisions: + # systemd spawns core services deterministically, so an unrelated + # process (e.g. cron) can land on the exact same PID and jiffy + # count as a previous gateway. If both start_times are known and + # match but the live process is not a gateway, and we can confirm + # that by reading its cmdline, the lock is stale. + if ( + not stale + and existing.get("start_time") is not None + and current_start is not None + and not _looks_like_gateway_process(existing_pid) + ): + live_cmdline = _read_process_cmdline(existing_pid) + if live_cmdline is not None: + stale = True # Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped # processes still appear alive to _pid_exists but are not # actually running. Treat them as stale so --replace works. diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index bbf9d95709..e8d2f57485 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -636,6 +636,36 @@ class TestScopedLocks: assert removed == 0 assert reused_pid_lock.exists() + def test_acquire_scoped_lock_replaces_reused_pid_even_with_matching_start_time(self, tmp_path, monkeypatch): + """Regression: boot-time PID+start_time collision must not block gateway startup. + + On Linux, systemd assigns PIDs and jiffy start_times deterministically + across reboots. A core service (e.g. cron) can land on the exact same + PID and start_time as a previous gateway. The start_time check passes, + but the live process is not a gateway — the lock must be evicted. + """ + monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks")) + lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_path.write_text(json.dumps({ + "pid": 840, + "start_time": 123, + "kind": "hermes-gateway", + "argv": ["/usr/bin/python", "-m", "hermes_cli.main", "gateway", "run"], + })) + + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 123) + monkeypatch.setattr(status, "_looks_like_gateway_process", lambda pid: False) + monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "/usr/sbin/nginx") + + acquired, existing = status.acquire_scoped_lock("telegram-bot-token", "secret", metadata={"platform": "telegram"}) + + assert acquired is True + payload = json.loads(lock_path.read_text()) + assert payload["pid"] == os.getpid() + assert payload["metadata"]["platform"] == "telegram" + class TestTakeoverMarker: """Tests for the --replace takeover marker. From f79b109f4f83435e51deff206edb1eec217358f6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:07:42 -0700 Subject: [PATCH 19/92] chore: map 0xneobyte release author --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index dbf61be2e6..5d4dfcfa3f 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -84,6 +84,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "tharushkadinujaya05@gmail.com": "0xneobyte", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", From 2cddc9c8955498dda6c8f4e33e29f24502bcd691 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 15 Jun 2026 19:08:44 +0800 Subject: [PATCH 20/92] fix(bedrock): check boto3 version >= 1.34.59 before using converse_stream converse() and converse_stream() were added in boto3 1.34.59. When Hermes is installed editable into system Python (e.g. Ubuntu 24.04 ships 1.34.46), the system boto3 takes precedence and calls to converse_stream fail with AttributeError. Add an early version check in _require_boto3() that raises a clear RuntimeError with upgrade instructions. --- agent/bedrock_adapter.py | 21 +++++++++++-- tests/agent/test_bedrock_adapter.py | 49 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index a09e1bc5d8..d5dab7baff 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {} _bedrock_control_client_cache: Dict[str, Any] = {} +_MIN_BOTO3_VERSION = (1, 34, 59) + + def _require_boto3(): - """Import boto3, raising a clear error if not installed.""" + """Import boto3, raising a clear error if not installed or too old.""" try: import boto3 - return boto3 except ImportError: raise ImportError( "The 'boto3' package is required for the AWS Bedrock provider. " "Install it with: pip install boto3\n" "Or install Hermes with Bedrock support: pip install -e '.[bedrock]'" ) + # converse() / converse_stream() were added in boto3 1.34.59. + # When Hermes is installed editable into system Python, the system boto3 + # (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv + # version pinned in pyproject.toml. + try: + version = tuple(int(x) for x in boto3.__version__.split(".")[:3]) + except (AttributeError, ValueError): + return boto3 # can't parse — don't block on version check + if version < _MIN_BOTO3_VERSION: + raise RuntimeError( + f"boto3 {boto3.__version__} does not support converse_stream " + f"(minimum 1.34.59 required). Upgrade with: " + f"pip install --upgrade boto3" + ) + return boto3 def _get_bedrock_runtime_client(region: str): diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index f8190bf0d7..ac2b557aea 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -1663,3 +1663,52 @@ class TestCallConverseStreamIamFallback: assert result.choices[0].message.content == "hi" # Not a stale connection — client stays cached. assert _bedrock_runtime_client_cache.get("us-east-1") is client + + +# --------------------------------------------------------------------------- +# boto3 version check +# --------------------------------------------------------------------------- + + +class TestRequireBoto3VersionCheck: + """Test that _require_boto3() rejects boto3 versions older than 1.34.59.""" + + def test_raises_runtime_error_when_boto3_too_old(self): + """boto3 < 1.34.59 should raise RuntimeError with upgrade instructions.""" + from agent.bedrock_adapter import _require_boto3 + + fake_boto3 = MagicMock() + fake_boto3.__version__ = "1.34.46" + with patch.dict("sys.modules", {"boto3": fake_boto3}): + with pytest.raises(RuntimeError, match="does not support converse_stream"): + _require_boto3() + + def test_accepts_boto3_at_minimum_version(self): + """boto3 == 1.34.59 should be accepted.""" + from agent.bedrock_adapter import _require_boto3 + + fake_boto3 = MagicMock() + fake_boto3.__version__ = "1.34.59" + with patch.dict("sys.modules", {"boto3": fake_boto3}): + result = _require_boto3() + assert result is fake_boto3 + + def test_accepts_newer_boto3(self): + """boto3 > 1.34.59 should be accepted.""" + from agent.bedrock_adapter import _require_boto3 + + fake_boto3 = MagicMock() + fake_boto3.__version__ = "1.42.89" + with patch.dict("sys.modules", {"boto3": fake_boto3}): + result = _require_boto3() + assert result is fake_boto3 + + def test_accepts_boto3_with_unparseable_version(self): + """If version string can't be parsed, don't block on version check.""" + from agent.bedrock_adapter import _require_boto3 + + fake_boto3 = MagicMock() + fake_boto3.__version__ = "dev" + with patch.dict("sys.modules", {"boto3": fake_boto3}): + result = _require_boto3() + assert result is fake_boto3 From c1a70a5439258b8534821d7d20cf96666e32da8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Mon, 15 Jun 2026 19:20:53 +0800 Subject: [PATCH 21/92] =?UTF-8?q?=F0=9F=90=9B=20fix(disk-cleanup):=20prune?= =?UTF-8?q?=20protected=20cleanup=20walks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/disk-cleanup/disk_cleanup.py | 63 +++++++++++++++-------- tests/plugins/test_disk_cleanup_plugin.py | 22 ++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index fddb62dacb..335a665091 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -348,36 +348,57 @@ def quick() -> Dict[str, Any]: else: new_tracked.append(item) - # Remove empty dirs under HERMES_HOME (but leave HERMES_HOME itself and - # a short list of well-known top-level state dirs alone — a fresh install - # has these empty, and deleting them would surprise the user). + # Remove empty dirs under HERMES_HOME, but never recurse into known + # durable state trees. Some installs place the Hermes checkout, venv, + # and desktop build under HERMES_HOME; a full rglob over that tree can + # stall the gateway event loop for minutes. hermes_home = get_hermes_home() _PROTECTED_TOP_LEVEL = { "logs", "memories", "sessions", "cron", "cronjobs", "cache", "skills", "plugins", "disk-cleanup", "optional-skills", "hermes-agent", "backups", "profiles", ".worktrees", } + _SWEEP_PRUNE_DIRS = { + ".git", "node_modules", "venv", ".venv", + "site-packages", "__pycache__", + } empty_removed = 0 + sweep_stack: List[Path] = [] try: - for dirpath in sorted(hermes_home.rglob("*"), reverse=True): - if not dirpath.is_dir() or dirpath == hermes_home: - continue - try: - rel_parts = dirpath.relative_to(hermes_home).parts - except ValueError: - continue - # Skip the well-known top-level state dirs themselves. - if len(rel_parts) == 1 and rel_parts[0] in _PROTECTED_TOP_LEVEL: - continue - try: - if not any(dirpath.iterdir()): - dirpath.rmdir() - empty_removed += 1 - _log(f"DELETED: {dirpath} (empty dir)") - except OSError: - pass + for top in hermes_home.iterdir(): + if ( + top.is_dir() + and not top.is_symlink() + and top.name not in _PROTECTED_TOP_LEVEL + and top.name not in _SWEEP_PRUNE_DIRS + ): + sweep_stack.append(top) except OSError: - pass + sweep_stack = [] + + found_dirs: List[Path] = [] + while sweep_stack: + dirpath = sweep_stack.pop() + found_dirs.append(dirpath) + try: + for child in dirpath.iterdir(): + if ( + child.is_dir() + and not child.is_symlink() + and child.name not in _SWEEP_PRUNE_DIRS + ): + sweep_stack.append(child) + except OSError: + pass + + for dirpath in sorted(found_dirs, key=lambda p: len(p.parts), reverse=True): + try: + if not any(dirpath.iterdir()): + dirpath.rmdir() + empty_removed += 1 + _log(f"DELETED: {dirpath} (empty dir)") + except OSError: + pass save_tracked(new_tracked) _log( diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index 783644d388..b7108224fc 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -352,6 +352,28 @@ class TestTrackForgetQuick: for d in ("logs", "memories", "sessions", "cron", "cache"): assert (_isolate_env / d).exists(), f"{d}/ should be preserved" + def test_quick_does_not_descend_into_protected_top_level_dirs(self, _isolate_env): + dg = _load_lib() + protected_empty = ( + _isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty" + ) + protected_empty.mkdir(parents=True) + + summary = dg.quick() + + assert summary["empty_dirs"] == 0 + assert protected_empty.exists() + + def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env): + dg = _load_lib() + managed_empty = _isolate_env / "scratch" / "nested" / "empty" + managed_empty.mkdir(parents=True) + + summary = dg.quick() + + assert summary["empty_dirs"] == 3 + assert not (_isolate_env / "scratch").exists() + class TestStatus: def test_empty_status(self, _isolate_env): From 40699c329265a34d7117a1cc0d9ac53098b28ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Mon, 15 Jun 2026 19:27:38 +0800 Subject: [PATCH 22/92] =?UTF-8?q?=F0=9F=90=9B=20fix(disk-cleanup):=20avoid?= =?UTF-8?q?=20brittle=20sweep=20review=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/disk-cleanup/disk_cleanup.py | 56 ++++++++++++----------- tests/plugins/test_disk_cleanup_plugin.py | 6 +-- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index 335a665091..8f70631ea8 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -144,6 +144,17 @@ ALLOWED_CATEGORIES = { "chrome-profile", "cron-output", "other", } +_EMPTY_DIR_PROTECTED_TOP_LEVEL = frozenset({ + "logs", "memories", "sessions", "cron", "cronjobs", + "cache", "skills", "plugins", "disk-cleanup", "optional-skills", + "hermes-agent", "backups", "profiles", ".worktrees", +}) + +_EMPTY_DIR_SWEEP_PRUNE_DIRS = frozenset({ + ".git", "node_modules", "venv", ".venv", + "site-packages", "__pycache__", +}) + # Paths under $HERMES_HOME that must NEVER be deleted by quick(), # regardless of what the stored category says. This is a defense-in-depth @@ -353,50 +364,41 @@ def quick() -> Dict[str, Any]: # and desktop build under HERMES_HOME; a full rglob over that tree can # stall the gateway event loop for minutes. hermes_home = get_hermes_home() - _PROTECTED_TOP_LEVEL = { - "logs", "memories", "sessions", "cron", "cronjobs", - "cache", "skills", "plugins", "disk-cleanup", "optional-skills", - "hermes-agent", "backups", "profiles", ".worktrees", - } - _SWEEP_PRUNE_DIRS = { - ".git", "node_modules", "venv", ".venv", - "site-packages", "__pycache__", - } empty_removed = 0 - sweep_stack: List[Path] = [] + sweep_stack: List[Tuple[Path, bool]] = [] try: for top in hermes_home.iterdir(): if ( top.is_dir() and not top.is_symlink() - and top.name not in _PROTECTED_TOP_LEVEL - and top.name not in _SWEEP_PRUNE_DIRS + and top.name not in _EMPTY_DIR_PROTECTED_TOP_LEVEL + and top.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS ): - sweep_stack.append(top) + sweep_stack.append((top, False)) except OSError: sweep_stack = [] - found_dirs: List[Path] = [] while sweep_stack: - dirpath = sweep_stack.pop() - found_dirs.append(dirpath) + dirpath, visited = sweep_stack.pop() + if visited: + try: + if not any(dirpath.iterdir()): + dirpath.rmdir() + empty_removed += 1 + _log(f"DELETED: {dirpath} (empty dir)") + except OSError: + pass + continue + + sweep_stack.append((dirpath, True)) try: for child in dirpath.iterdir(): if ( child.is_dir() and not child.is_symlink() - and child.name not in _SWEEP_PRUNE_DIRS + and child.name not in _EMPTY_DIR_SWEEP_PRUNE_DIRS ): - sweep_stack.append(child) - except OSError: - pass - - for dirpath in sorted(found_dirs, key=lambda p: len(p.parts), reverse=True): - try: - if not any(dirpath.iterdir()): - dirpath.rmdir() - empty_removed += 1 - _log(f"DELETED: {dirpath} (empty dir)") + sweep_stack.append((child, False)) except OSError: pass diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index b7108224fc..38fffd5fbf 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -359,9 +359,8 @@ class TestTrackForgetQuick: ) protected_empty.mkdir(parents=True) - summary = dg.quick() + dg.quick() - assert summary["empty_dirs"] == 0 assert protected_empty.exists() def test_quick_removes_empty_dirs_in_managed_subtrees(self, _isolate_env): @@ -369,9 +368,8 @@ class TestTrackForgetQuick: managed_empty = _isolate_env / "scratch" / "nested" / "empty" managed_empty.mkdir(parents=True) - summary = dg.quick() + dg.quick() - assert summary["empty_dirs"] == 3 assert not (_isolate_env / "scratch").exists() From a688d2a1bd2941c39a7c9efd4d97fda399735266 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:07:49 -0700 Subject: [PATCH 23/92] test: assert disk cleanup prunes protected walks --- tests/plugins/test_disk_cleanup_plugin.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index 38fffd5fbf..52afddc9c4 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -352,13 +352,24 @@ class TestTrackForgetQuick: for d in ("logs", "memories", "sessions", "cron", "cache"): assert (_isolate_env / d).exists(), f"{d}/ should be preserved" - def test_quick_does_not_descend_into_protected_top_level_dirs(self, _isolate_env): + def test_quick_does_not_descend_into_protected_top_level_dirs( + self, _isolate_env, monkeypatch + ): dg = _load_lib() protected_empty = ( _isolate_env / "hermes-agent" / "node_modules" / "pkg" / "empty" ) protected_empty.mkdir(parents=True) + original_iterdir = Path.iterdir + + def guarded_iterdir(path): + if path == _isolate_env / "hermes-agent": + raise AssertionError("quick() descended into protected hermes-agent/") + return original_iterdir(path) + + monkeypatch.setattr(Path, "iterdir", guarded_iterdir) + dg.quick() assert protected_empty.exists() From ad58dd51ac173172fb85223aec5f3aeba1abfaf8 Mon Sep 17 00:00:00 2001 From: xtymac Date: Mon, 15 Jun 2026 20:39:55 +0900 Subject: [PATCH 24/92] redact secrets in API request debug dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dump_api_request_debug() masks the provider Authorization header but writes the request `body` (system prompt, tool defs, context-embedded values) and the error message raw via atomic_json_write. This path also fires unconditionally on API errors (not only under HERMES_DUMP_REQUESTS), so any secret surfaced into context (e.g. an integration token) lands in cleartext at request_dump_*.json on every failed call. Run the serialized dump through the existing redact_sensitive_text() scrubber (already used for logs/tool output) before persisting and before the HERMES_DUMP_REQUEST_STDOUT print; preserve atomicity via temp-file + Path.replace. Also add the Notion internal-integration prefix (ntn_) to _PREFIX_PATTERNS so bare values are caught. Per SECURITY.md §3.2 this is a redaction (in-process heuristic) hardening, not a §3.1 vulnerability. Refs #46583. --- agent/agent_runtime_helpers.py | 16 ++++++++++++++-- agent/redact.py | 1 + 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index cae1a685a5..b0ea2f6211 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1217,12 +1217,24 @@ def dump_api_request_debug( timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json" - atomic_json_write(dump_file, dump_payload, default=str) + + # Redact secrets before persisting/printing. This dump captures the + # full request body (system prompt, tool defs, context-embedded + # values), and this path fires unconditionally on API errors — so it + # otherwise lands any context-embedded secret in cleartext on disk. + # Run the serialized dump through the same scrubber used for logs/tool + # output. Atomicity preserved via temp-file + Path.replace. + from agent.redact import redact_sensitive_text + _serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str) + _redacted = redact_sensitive_text(_serialized, force=True) + _tmp = dump_file.with_name(dump_file.name + ".tmp") + _tmp.write_text(_redacted, encoding="utf-8") + _tmp.replace(dump_file) agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}") if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"): - print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)) + print(_redacted) return dump_file except Exception as dump_error: diff --git a/agent/redact.py b/agent/redact.py index 6c713cb4e4..de247ec0ad 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [ r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key r"brv_[A-Za-z0-9]{10,}", # ByteRover API key r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key + r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token ] # ENV assignment patterns: KEY=value where KEY contains a secret-like name From aab2e99bae63bfd7f780a6c08dce6ff82f8426c6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:19:34 -0700 Subject: [PATCH 25/92] test: cover request debug dump redaction Keep request dump writes on the shared atomic JSON path, add regression coverage for request body/error/stdout redaction, and map the salvaged contributor email for release attribution. --- agent/agent_runtime_helpers.py | 11 ++-- scripts/release.py | 1 + tests/agent/test_redact.py | 4 ++ .../test_run_agent_codex_responses.py | 52 +++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index b0ea2f6211..884866dc11 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1223,18 +1223,17 @@ def dump_api_request_debug( # values), and this path fires unconditionally on API errors — so it # otherwise lands any context-embedded secret in cleartext on disk. # Run the serialized dump through the same scrubber used for logs/tool - # output. Atomicity preserved via temp-file + Path.replace. + # output, then hand the resulting payload back to the shared atomic + # JSON writer so request dumps keep the same write semantics as before. from agent.redact import redact_sensitive_text _serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str) - _redacted = redact_sensitive_text(_serialized, force=True) - _tmp = dump_file.with_name(dump_file.name + ".tmp") - _tmp.write_text(_redacted, encoding="utf-8") - _tmp.replace(dump_file) + _redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True)) + atomic_json_write(dump_file, _redacted_payload, default=str) agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}") if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"): - print(_redacted) + print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str)) return dump_file except Exception as dump_error: diff --git a/scripts/release.py b/scripts/release.py index 5d4dfcfa3f..a21bd36ab5 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,6 +46,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "kenmege@yahoo.com": "Kenmege", + "tianying.x@eukarya.io": "xtymac", "dkobi16@gmail.com": "Diyoncrz18", "arnaud@nolimitdevelopment.com": "ali-nld", "sswdarius@gmail.com": "necoweb3", diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index e956b2a3ab..472b97fb39 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -53,6 +53,10 @@ class TestKnownPrefixes: result = redact_sensitive_text("fal_abc123def456ghi789jkl") assert "abc123def456" not in result + def test_notion_internal_integration_token(self): + result = redact_sensitive_text("ntn_abc123def456ghi789jkl") + assert "abc123def456" not in result + def test_short_token_fully_masked(self): result = redact_sensitive_text("key=sk-short1234567") assert "***" in result diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index a031907611..14e01d9fec 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1913,6 +1913,58 @@ def test_dump_api_request_debug_uses_chat_completions_url(monkeypatch, tmp_path) assert payload["request"]["url"] == "http://127.0.0.1:9208/v1/chat/completions" +def test_dump_api_request_debug_redacts_request_and_error_secrets(monkeypatch, tmp_path, capsys): + """Request debug dumps should redact secrets before disk/stdout output.""" + import json + + _patch_agent_bootstrap(monkeypatch) + monkeypatch.setenv("HERMES_DUMP_REQUEST_STDOUT", "1") + agent = run_agent.AIAgent( + model="gpt-4o", + base_url="http://127.0.0.1:9208/v1", + api_key="sk-ant-providersecret1234567890", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + agent.logs_dir = tmp_path + + notion_token = "ntn_abc123def456ghi789jkl" + error_secret = "sk-ant-errorsecret1234567890" + response_secret = "sk-ant-responsesecret1234567890" + response = SimpleNamespace(status_code=400, text=f"provider echoed {response_secret}") + + class ProviderError(RuntimeError): + body: object + response: object + + error = ProviderError(f"bad token {error_secret}") + error.body = {"message": f"bad token {error_secret}"} + error.response = response + + dump_file = agent._dump_api_request_debug( + { + "model": "gpt-4o", + "messages": [{"role": "user", "content": f"use {notion_token}"}], + "metadata": {"NOTION_API_KEY": notion_token}, + }, + reason="provider_error", + error=error, + ) + + assert dump_file is not None + dumped_text = dump_file.read_text() + stdout_text = capsys.readouterr().out + for raw in (notion_token, error_secret, response_secret, "providersecret1234567890"): + assert raw not in dumped_text + assert raw not in stdout_text + + payload = json.loads(dumped_text) + assert payload["request"]["headers"]["Authorization"].startswith("Bearer sk-ant-p...") + assert "***" in dumped_text or "..." in dumped_text + + # --- Reasoning-only response tests (fix for empty content retry loop) --- From febdddb41af5b65d04cf38742546b705255f9678 Mon Sep 17 00:00:00 2001 From: Veritas-7 <138671361+Veritas-7@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:25:57 +0900 Subject: [PATCH 26/92] fix(auth): refresh xAI OAuth tokens earlier --- hermes_cli/auth.py | 7 ++- .../test_auth_xai_oauth_provider.py | 60 +++++++++---------- tests/hermes_cli/test_xai_oauth_refresh.py | 43 +++++++++++++ 3 files changed, 79 insertions(+), 31 deletions(-) create mode 100644 tests/hermes_cli/test_xai_oauth_refresh.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0950d225f7..f7857b4854 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -103,7 +103,12 @@ XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:acces XAI_OAUTH_REDIRECT_HOST = "127.0.0.1" XAI_OAUTH_REDIRECT_PORT = 56121 XAI_OAUTH_REDIRECT_PATH = "/callback" -XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 +# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in +# current SuperGrok flows). A two-minute refresh window is too narrow for +# gateway/cron workloads that may only touch the provider every 30 minutes, +# leaving brief but noisy credential-expiry gaps. Refresh up to one hour +# early so ordinary runtime calls keep the token warm without user reauth. +XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600 QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token" QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 diff --git a/tests/hermes_cli/test_auth_xai_oauth_provider.py b/tests/hermes_cli/test_auth_xai_oauth_provider.py index 05978ddc06..32c9437337 100644 --- a/tests/hermes_cli/test_auth_xai_oauth_provider.py +++ b/tests/hermes_cli/test_auth_xai_oauth_provider.py @@ -152,7 +152,7 @@ def test_xai_access_token_is_expiring_returns_true_for_expired_jwt(): def test_xai_access_token_is_expiring_returns_false_for_fresh_jwt(): - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) assert _xai_access_token_is_expiring(fresh, 0) is False @@ -476,7 +476,7 @@ def test_read_xai_oauth_tokens_missing_refresh_token(tmp_path, monkeypatch): def test_resolve_xai_runtime_credentials_returns_singleton_state(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) @@ -501,7 +501,7 @@ def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monk ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) called = {"count": 0} def _fake_refresh(tokens, **kwargs): @@ -520,7 +520,7 @@ def test_resolve_xai_runtime_credentials_refreshes_expiring_token(tmp_path, monk def test_resolve_xai_runtime_credentials_force_refresh(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth( hermes_home, access_token=fresh, @@ -546,7 +546,7 @@ def test_resolve_xai_runtime_credentials_force_refresh(tmp_path, monkeypatch): def test_resolve_xai_runtime_credentials_honours_env_base_url(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://custom.x.ai/v1/") @@ -669,7 +669,7 @@ def test_resolve_xai_runtime_credentials_rejects_off_origin_env_base_url(tmp_pat # the resolver MUST silently fall back to the default rather than ship # the OAuth bearer to the attacker. hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("XAI_BASE_URL", "https://attacker.example/v1") @@ -807,7 +807,7 @@ def test_resolve_credentials_does_not_quarantine_on_transient_refresh_failure( def test_get_xai_oauth_auth_status_logged_in_via_singleton(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -910,7 +910,7 @@ def test_format_auth_error_tier_denied_does_not_suggest_relogin(): def test_refresh_xai_oauth_pure_returns_updated_tokens(monkeypatch): - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) response = _StubHTTPResponse( 200, { @@ -941,7 +941,7 @@ def test_refresh_xai_oauth_pure_returns_updated_tokens(monkeypatch): def test_refresh_xai_oauth_pure_keeps_refresh_token_when_response_omits_it(monkeypatch): """Some OAuth providers don't rotate refresh tokens — preserve the old one.""" - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) response = _StubHTTPResponse( 200, { @@ -1080,7 +1080,7 @@ def test_refresh_xai_oauth_pure_accepts_apex_and_subdomain_endpoints(monkeypatch ``*.x.ai`` subdomain (e.g. ``auth.x.ai`` today, future migrations to ``accounts.x.ai`` etc.). Without subdomain support we'd lock the integration to whatever xAI happens to use today.""" - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) response = _StubHTTPResponse( 200, {"access_token": new_access, "expires_in": 3600, "token_type": "Bearer"}, @@ -1172,7 +1172,7 @@ def test_credential_pool_seeds_xai_oauth_from_singleton(tmp_path, monkeypatch): from agent.credential_pool import load_pool hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1214,7 +1214,7 @@ def test_credential_pool_seed_respects_suppression(tmp_path, monkeypatch): from agent.credential_pool import load_pool hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1246,7 +1246,7 @@ def test_auth_remove_xai_oauth_clears_singleton_and_sticks(tmp_path, monkeypatch from types import SimpleNamespace hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh, refresh_token="rt-1") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1294,7 +1294,7 @@ def test_pool_sync_back_writes_to_singleton(tmp_path, monkeypatch): _setup_hermes_auth(hermes_home, access_token=expired, refresh_token="rt-old") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) def _fake_refresh(access_token, refresh_token, **kwargs): assert refresh_token == "rt-old" @@ -1334,7 +1334,7 @@ def test_runtime_provider_uses_pool_entry_for_xai_oauth(tmp_path, monkeypatch): from hermes_cli.runtime_provider import resolve_runtime_provider hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) @@ -1360,7 +1360,7 @@ def test_runtime_provider_default_base_url_when_pool_entry_missing_url(tmp_path, monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) monkeypatch.delenv("XAI_BASE_URL", raising=False) - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) pool = load_pool("xai-oauth") pool.add_entry( PooledCredential( @@ -1404,7 +1404,7 @@ def test_pool_entry_needs_refresh_when_jwt_within_skew(tmp_path, monkeypatch): (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - # Token expires in 30s — well inside the 120s skew window. + # Token expires in 30s — well inside the proactive refresh skew window. near_expiry = _jwt_with_exp(int(time.time()) + 30) pool = load_pool("xai-oauth") entry = PooledCredential( @@ -1433,7 +1433,7 @@ def test_pool_entry_no_refresh_for_fresh_jwt(tmp_path, monkeypatch): (hermes_home / "auth.json").write_text(json.dumps({"version": 1, "providers": {}})) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) pool = load_pool("xai-oauth") entry = PooledCredential( provider="xai-oauth", @@ -1463,7 +1463,7 @@ def test_pool_select_proactively_refreshes_expiring_token(tmp_path, monkeypatch) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) near_expiry = _jwt_with_exp(int(time.time()) + 30) - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) refresh_calls = {"count": 0} @@ -1520,7 +1520,7 @@ def test_pool_try_refresh_current_handles_xai_oauth(tmp_path, monkeypatch): # We simulate the scenario where the server rejected the token (401) # despite client-side expiry math saying it's still valid (e.g. clock # skew, server-side revocation, token bound to a session that expired). - seemingly_fresh = _jwt_with_exp(int(time.time()) + 3600) + seemingly_fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) new_access = _jwt_with_exp(int(time.time()) + 7200) def _fake_refresh(access_token, refresh_token, **kwargs): @@ -1577,7 +1577,7 @@ def test_pool_refresh_marks_entry_exhausted_on_failure(tmp_path, monkeypatch): monkeypatch.setattr("hermes_cli.auth.refresh_xai_oauth_pure", _fake_refresh_fail) pool = load_pool("xai-oauth") - seemingly_fresh = _jwt_with_exp(int(time.time()) + 3600) + seemingly_fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) pool.add_entry( PooledCredential( provider="xai-oauth", @@ -1609,7 +1609,7 @@ def test_pool_seeded_entry_sync_back_after_refresh(tmp_path, monkeypatch): _setup_hermes_auth(hermes_home, access_token=near_expiry, refresh_token="rt-singleton") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) def _fake_refresh(access_token, refresh_token, **kwargs): assert refresh_token == "rt-singleton" @@ -1658,7 +1658,7 @@ def test_pool_refresh_adopts_singleton_tokens_when_consumed_elsewhere(tmp_path, # Now simulate "another process refreshed the tokens" by overwriting # the singleton on disk WITHOUT touching this process's pool object. - other_process_at = _jwt_with_exp(int(time.time()) + 3600) + other_process_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) raw = json.loads((hermes_home / "auth.json").read_text()) raw["providers"]["xai-oauth"]["tokens"] = { "access_token": other_process_at, @@ -1708,7 +1708,7 @@ def test_pool_refresh_recovers_when_other_process_already_refreshed(tmp_path, mo pool = load_pool("xai-oauth") - other_process_at = _jwt_with_exp(int(time.time()) + 3600) + other_process_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) def _fake_refresh(access_token, refresh_token, **kwargs): # Simulate the racing process winning at the auth server right @@ -1750,7 +1750,7 @@ def test_pool_exhausted_xai_entry_recovers_after_singleton_refresh(tmp_path, mon from dataclasses import replace hermes_home = tmp_path / "hermes" - stale_at = _jwt_with_exp(int(time.time()) + 3600) + stale_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=stale_at, refresh_token="rt-stale") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1804,7 +1804,7 @@ def test_pool_manual_xai_entry_not_synced_from_singleton(tmp_path, monkeypatch): import uuid hermes_home = tmp_path / "hermes" - singleton_at = _jwt_with_exp(int(time.time()) + 3600) + singleton_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1842,7 +1842,7 @@ def test_pool_manual_entry_does_not_sync_back_to_singleton(tmp_path, monkeypatch hermes_home = tmp_path / "hermes" # Singleton has its own tokens (separate login). - singleton_at = _jwt_with_exp(int(time.time()) + 3600) + singleton_at = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=singleton_at, refresh_token="rt-singleton") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1911,7 +1911,7 @@ def test_auxiliary_client_routes_xai_oauth_through_responses_api(tmp_path, monke ) hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False) @@ -1955,7 +1955,7 @@ def test_auxiliary_client_xai_oauth_requires_explicit_model(tmp_path, monkeypatc from agent.auxiliary_client import resolve_provider_client hermes_home = tmp_path / "hermes" - fresh = _jwt_with_exp(int(time.time()) + 3600) + fresh = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) _setup_hermes_auth(hermes_home, access_token=fresh) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) @@ -1991,7 +1991,7 @@ def test_pool_sync_back_preserves_active_provider(tmp_path, monkeypatch): raw["active_provider"] = "openrouter" (hermes_home / "auth.json").write_text(json.dumps(raw)) - new_access = _jwt_with_exp(int(time.time()) + 3600) + new_access = _jwt_with_exp(int(time.time()) + 2 * 60 * 60) def _fake_refresh(access_token, refresh_token, **kwargs): return { diff --git a/tests/hermes_cli/test_xai_oauth_refresh.py b/tests/hermes_cli/test_xai_oauth_refresh.py new file mode 100644 index 0000000000..e954778a5a --- /dev/null +++ b/tests/hermes_cli/test_xai_oauth_refresh.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import base64 +import json +import time + +from hermes_cli import auth + + +def _jwt_with_exp(exp: int) -> str: + header = ( + base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()) + .decode() + .rstrip("=") + ) + payload = ( + base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()) + .decode() + .rstrip("=") + ) + return f"{header}.{payload}.sig" + + +def test_xai_oauth_refresh_skew_is_one_hour() -> None: + assert auth.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS == 3600 + + +def test_xai_oauth_token_expiring_uses_one_hour_skew() -> None: + token = _jwt_with_exp(int(time.time()) + 30 * 60) + + assert auth._xai_access_token_is_expiring( + token, + auth.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ) + + +def test_xai_oauth_token_not_expiring_beyond_one_hour_skew() -> None: + token = _jwt_with_exp(int(time.time()) + 90 * 60) + + assert not auth._xai_access_token_is_expiring( + token, + auth.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ) From c17469cb19dab7bf0c2cc2efc494c190b30b3796 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:08:16 -0700 Subject: [PATCH 27/92] chore: map Veritas-7 release attribution Add the contributor noreply email used by the salvaged xAI OAuth refresh-skew commit so release notes credit the original author. --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index a21bd36ab5..d316e36b1c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -86,6 +86,7 @@ AUTHOR_MAP = { "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", "tharushkadinujaya05@gmail.com": "0xneobyte", + "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", From ea49a79633d93202d8e495648b2586ee5a1fbecc Mon Sep 17 00:00:00 2001 From: Keiron McCammon Date: Mon, 15 Jun 2026 07:54:26 -0400 Subject: [PATCH 28/92] fix(messaging): route WhatsApp group JIDs to the target, not the home DM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_message(target="whatsapp:") silently delivered to the configured home DM instead of the requested group. Two gaps: 1. _parse_target_ref had no WhatsApp branch. Group JIDs (@g.us), user JIDs (@s.whatsapp.net), linked-identity JIDs (@lid), and broadcast/newsletter JIDs matched no pattern and fell through to `return None, None, False`, so the caller treated them as unresolvable and used the home channel. The bridge's /send endpoint accepts any chatId, so only the tool-side target parsing was at fault. Add a whatsapp branch that recognizes native JIDs as explicit targets. The pre-existing '+'-prefixed E.164 path is preserved. 2. WhatsApp groups have no human-friendly name — the channel directory is regenerated from session data on a timer, so a group shows up as its raw 18-digit JID and any hand-edit to channel_directory.json is clobbered on the next rebuild. Add a user-maintained alias overlay (~/.hermes/channel_aliases.json) re-applied on every build AND every load, giving durable friendly names and letting a freshly-created group be pre-named before its first message. Tests: TestParseTargetRefWhatsAppJID (7 cases) for the parser; TestChannelAliases (7 cases) for the overlay, plus an autouse fixture isolating CHANNEL_ALIASES_PATH so a real alias file can't leak into the existing directory tests. --- gateway/channel_directory.py | 64 ++++++++++++++++- tests/gateway/test_channel_directory.py | 92 +++++++++++++++++++++++++ tests/tools/test_send_message_tool.py | 52 ++++++++++++++ tools/send_message_tool.py | 14 ++++ 4 files changed, 219 insertions(+), 3 deletions(-) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ff4af85a89..469db24fc2 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -17,6 +17,53 @@ from utils import atomic_json_write logger = logging.getLogger(__name__) DIRECTORY_PATH = get_hermes_home() / "channel_directory.json" +# User-maintained friendly-name overlay. The directory is fully regenerated +# from live adapters + session data on a timer, so hand-edits to +# channel_directory.json don't survive. Aliases declared here are re-applied +# on every build AND every load, giving durable human-friendly names (and +# letting you pre-name a chat before it has produced any traffic). +# Format: {"": {"": "", ...}, ...} +CHANNEL_ALIASES_PATH = get_hermes_home() / "channel_aliases.json" + + +def _load_channel_aliases() -> Dict[str, Dict[str, str]]: + if not CHANNEL_ALIASES_PATH.exists(): + return {} + try: + with open(CHANNEL_ALIASES_PATH, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _apply_channel_aliases(platforms: Dict[str, Any]) -> None: + """Overlay friendly names onto directory entries by chat_id. + + Renames matching entries in place; injects a placeholder entry for an + aliased id that hasn't been discovered yet (so a freshly-created group is + addressable by name before its first message). Mutates *platforms*. + """ + aliases = _load_channel_aliases() + for plat_name, id_map in aliases.items(): + if not isinstance(id_map, dict): + continue + entries = platforms.setdefault(plat_name, []) + for chat_id, friendly in id_map.items(): + if not friendly: + continue + matched = False + for e in entries: + if e.get("id") == chat_id: + e["name"] = friendly + matched = True + if not matched: + entries.append({ + "id": chat_id, + "name": friendly, + "type": "group" if str(chat_id).endswith("@g.us") else "dm", + "thread_id": None, + }) def _normalize_channel_query(value: str) -> str: @@ -96,6 +143,9 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: except Exception: pass + # Overlay user-maintained friendly names before persisting. + _apply_channel_aliases(platforms) + directory = { "updated_at": datetime.now().isoformat(), "platforms": platforms, @@ -247,12 +297,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]: def load_directory() -> Dict[str, Any]: """Load the cached channel directory from disk.""" if not DIRECTORY_PATH.exists(): - return {"updated_at": None, "platforms": {}} + base = {"updated_at": None, "platforms": {}} + _apply_channel_aliases(base["platforms"]) + return base try: with open(DIRECTORY_PATH, encoding="utf-8") as f: - return json.load(f) + data = json.load(f) + # Re-apply aliases on read so friendly names take effect immediately, + # even between timed rebuilds and for brand-new alias entries. + _apply_channel_aliases(data.setdefault("platforms", {})) + return data except Exception: - return {"updated_at": None, "platforms": {}} + base = {"updated_at": None, "platforms": {}} + _apply_channel_aliases(base["platforms"]) + return base def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]: diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 18e8ae2fb0..3224e6941b 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -12,11 +12,26 @@ from gateway.channel_directory import ( resolve_channel_name, format_directory_for_display, load_directory, + _apply_channel_aliases, _build_from_sessions, _build_slack, ) +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_channel_aliases(tmp_path_factory): + """Point the alias overlay at a nonexistent path by default so a real + ~/.hermes/channel_aliases.json never leaks into directory tests. Tests + that exercise aliases patch CHANNEL_ALIASES_PATH themselves inside the + test body, which takes precedence over this outer patch.""" + missing = tmp_path_factory.mktemp("aliases") / "none.json" + with patch("gateway.channel_directory.CHANNEL_ALIASES_PATH", missing): + yield + + def _write_directory(tmp_path, platforms): """Helper to write a fake channel directory.""" data = {"updated_at": "2026-01-01T00:00:00", "platforms": platforms} @@ -480,3 +495,80 @@ class TestBuildSlack: entries = asyncio.run(_build_slack(_make_slack_adapter({"T1": client}))) assert entries == [] + + +class TestChannelAliases: + """The user-maintained alias overlay (channel_aliases.json) gives durable + friendly names that survive the timed directory rebuild.""" + + def _setup_aliases(self, tmp_path, aliases): + alias_file = tmp_path / "channel_aliases.json" + alias_file.write_text(json.dumps(aliases)) + return patch("gateway.channel_directory.CHANNEL_ALIASES_PATH", alias_file) + + def test_alias_renames_existing_entry_on_load(self, tmp_path): + cache_file = _write_directory(tmp_path, { + "whatsapp": [{"id": "120363@g.us", "name": "120363", "type": "group"}] + }) + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + self._setup_aliases(tmp_path, {"whatsapp": {"120363@g.us": "general"}}): + result = load_directory() + assert result["platforms"]["whatsapp"][0]["name"] == "general" + # And the friendly name resolves back to the JID + assert resolve_channel_name("whatsapp", "general") == "120363@g.us" + assert resolve_channel_name("whatsapp", "GENERAL") == "120363@g.us" + + def test_alias_injects_undiscovered_group(self, tmp_path): + """A group named in the alias file but not yet seen in any session is + still addressable by name (pre-naming before first traffic).""" + cache_file = _write_directory(tmp_path, {"whatsapp": []}) + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + self._setup_aliases(tmp_path, {"whatsapp": {"999@g.us": "marketing"}}): + assert resolve_channel_name("whatsapp", "marketing") == "999@g.us" + entries = load_directory()["platforms"]["whatsapp"] + injected = [e for e in entries if e["id"] == "999@g.us"] + assert injected and injected[0]["type"] == "group" + + def test_no_alias_file_is_noop(self, tmp_path): + cache_file = _write_directory(tmp_path, { + "whatsapp": [{"id": "120363@g.us", "name": "120363", "type": "group"}] + }) + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + patch("gateway.channel_directory.CHANNEL_ALIASES_PATH", tmp_path / "nope.json"): + result = load_directory() + assert result["platforms"]["whatsapp"][0]["name"] == "120363" + + def test_corrupt_alias_file_is_ignored(self, tmp_path): + cache_file = _write_directory(tmp_path, { + "whatsapp": [{"id": "120363@g.us", "name": "120363", "type": "group"}] + }) + bad = tmp_path / "channel_aliases.json" + bad.write_text("{not json") + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + patch("gateway.channel_directory.CHANNEL_ALIASES_PATH", bad): + result = load_directory() + assert result["platforms"]["whatsapp"][0]["name"] == "120363" + + def test_alias_persists_through_rebuild(self, tmp_path, monkeypatch): + """build_channel_directory must bake aliases into the written file so + they survive the periodic regeneration, not just live reads.""" + cache_file = tmp_path / "channel_directory.json" + monkeypatch.setattr("gateway.channel_directory._build_from_sessions", + lambda plat: [{"id": "120363@g.us", "name": "120363", + "type": "group", "thread_id": None}] + if plat == "whatsapp" else []) + with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ + self._setup_aliases(tmp_path, {"whatsapp": {"120363@g.us": "general"}}): + asyncio.run(build_channel_directory({})) + on_disk = json.loads(cache_file.read_text()) + names = [e["name"] for e in on_disk["platforms"]["whatsapp"] + if e["id"] == "120363@g.us"] + assert names == ["general"] + + def test_apply_aliases_handles_malformed_map(self): + """Non-dict alias entries must not raise.""" + platforms = {"whatsapp": [{"id": "1@g.us", "name": "1", "type": "group"}]} + with patch("gateway.channel_directory._load_channel_aliases", + return_value={"whatsapp": "not-a-dict", "telegram": None}): + _apply_channel_aliases(platforms) # should not raise + assert platforms["whatsapp"][0]["name"] == "1" diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index d1afb6c466..81cee1bb1d 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -1224,6 +1224,58 @@ class TestParseTargetRefE164: assert _parse_target_ref("matrix", "+15551234567")[2] is False +class TestParseTargetRefWhatsAppJID: + """_parse_target_ref accepts native WhatsApp JIDs as explicit targets. + + Regression: group JIDs (``@g.us``) and linked-identity JIDs + (``@lid``) matched no branch and fell through to home-channel + resolution, so ``send_message(target="whatsapp:")`` silently + delivered to the configured home DM instead of the requested group. + """ + + def test_group_jid_is_explicit(self): + chat_id, thread_id, is_explicit = _parse_target_ref( + "whatsapp", "120363408391911677@g.us" + ) + assert chat_id == "120363408391911677@g.us" + assert thread_id is None + assert is_explicit is True + + def test_user_jid_is_explicit(self): + chat_id, _, is_explicit = _parse_target_ref( + "whatsapp", "19255551234@s.whatsapp.net" + ) + assert chat_id == "19255551234@s.whatsapp.net" + assert is_explicit is True + + def test_lid_jid_is_explicit(self): + chat_id, _, is_explicit = _parse_target_ref( + "whatsapp", "149606612619433@lid" + ) + assert chat_id == "149606612619433@lid" + assert is_explicit is True + + def test_broadcast_and_newsletter_jids_are_explicit(self): + assert _parse_target_ref("whatsapp", "status@broadcast")[2] is True + assert _parse_target_ref("whatsapp", "120363000000000000@newsletter")[2] is True + + def test_whatsapp_e164_still_explicit_alongside_jids(self): + """The pre-existing '+'-prefixed E.164 path must keep working.""" + chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") + assert chat_id == "+15551234567" + assert is_explicit is True + + def test_jid_suffix_only_matches_whatsapp(self): + """WhatsApp JID suffixes must NOT be treated as explicit elsewhere.""" + assert _parse_target_ref("telegram", "120363408391911677@g.us")[2] is False + assert _parse_target_ref("signal", "149606612619433@lid")[2] is False + + def test_non_jid_whatsapp_target_falls_through(self): + """A bare friendly name is not a JID — it must fall through to + directory resolution (returns not-explicit so the caller can resolve).""" + assert _parse_target_ref("whatsapp", "general")[2] is False + + class TestParseTargetRefSlack: """_parse_target_ref recognizes Slack channel/user IDs as explicit.""" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index a37f9eb62a..3bbcbff8b1 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -40,6 +40,14 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE # downstream adapters (signal, etc.) expect. _PHONE_PLATFORMS = frozenset({"photon", "signal", "sms", "whatsapp"}) _E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$") +# WhatsApp JIDs: group chats (@g.us), individual users +# (@s.whatsapp.net), linked identities (@lid), and broadcast / +# newsletter chats. These are explicit native targets the bridge accepts +# verbatim — they must NOT fall through to home-channel resolution. +_WHATSAPP_JID_RE = re.compile( + r"^\s*[\w-]+@(?:g\.us|s\.whatsapp\.net|lid|broadcast|newsletter)\s*$", + re.IGNORECASE, +) # Email addresses — a valid email like "user@domain.com" should be treated as # an explicit target for the email platform, not fall through to channel-name # resolution which has no way to resolve a raw address. @@ -509,6 +517,12 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _EMAIL_TARGET_RE.fullmatch(target_ref) if match: return target_ref.strip(), None, True + if platform_name == "whatsapp": + # Native WhatsApp JIDs (group @g.us, user @s.whatsapp.net, @lid, etc.) + # are explicit targets — pass through verbatim. E.164 '+' numbers fall + # through to the _PHONE_PLATFORMS handler below. + if _WHATSAPP_JID_RE.fullmatch(target_ref): + return target_ref.strip(), None, True if platform_name in _PHONE_PLATFORMS: match = _E164_TARGET_RE.fullmatch(target_ref) if match: From 0d82060c74fb04ddefeabf790d7808cac3c7aca5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:13:34 -0700 Subject: [PATCH 29/92] fix: harden WhatsApp target alias salvage Add a parser-only routing regression that proves raw WhatsApp group JIDs bypass channel-directory resolution and home-channel fallback, include channel_aliases.json in quick state snapshots, harden malformed alias handling, and map Keiron McCammon for release attribution. --- gateway/channel_directory.py | 8 +- hermes_cli/backup.py | 1 + scripts/release.py | 1 + tests/gateway/test_channel_directory.py | 8 +- tests/hermes_cli/test_backup.py | 10 +++ tests/tools/test_send_message_target_parse.py | 76 ++++++++++++++++++- 6 files changed, 98 insertions(+), 6 deletions(-) diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 469db24fc2..fba3d58d51 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -49,12 +49,16 @@ def _apply_channel_aliases(platforms: Dict[str, Any]) -> None: if not isinstance(id_map, dict): continue entries = platforms.setdefault(plat_name, []) + if not isinstance(entries, list): + continue for chat_id, friendly in id_map.items(): - if not friendly: + if not isinstance(friendly, str) or not friendly.strip(): continue + chat_id = str(chat_id) + friendly = friendly.strip() matched = False for e in entries: - if e.get("id") == chat_id: + if isinstance(e, dict) and e.get("id") == chat_id: e["name"] = friendly matched = True if not matched: diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 62997528bd..e7e1e8ed90 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -510,6 +510,7 @@ _QUICK_STATE_FILES = ( "cron/jobs.json", "gateway_state.json", "channel_directory.json", + "channel_aliases.json", "processes.json", # Pairing stores (generic + per-platform JSONs outside state.db) "pairing", # legacy location (gateway/pairing.py) diff --git a/scripts/release.py b/scripts/release.py index d316e36b1c..95d12106ff 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -87,6 +87,7 @@ AUTHOR_MAP = { "dirtyren@users.noreply.github.com": "dirtyren", "tharushkadinujaya05@gmail.com": "0xneobyte", "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", + "keiron@onehanded.com": "kmccammon", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 3224e6941b..8c32eb8f40 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -566,9 +566,13 @@ class TestChannelAliases: assert names == ["general"] def test_apply_aliases_handles_malformed_map(self): - """Non-dict alias entries must not raise.""" + """Non-dict alias maps and non-string aliases must not raise.""" platforms = {"whatsapp": [{"id": "1@g.us", "name": "1", "type": "group"}]} with patch("gateway.channel_directory._load_channel_aliases", - return_value={"whatsapp": "not-a-dict", "telegram": None}): + return_value={ + "whatsapp": "not-a-dict", + "telegram": None, + "signal": {"+15551234567": 123}, + }): _apply_channel_aliases(platforms) # should not raise assert platforms["whatsapp"][0]["name"] == "1" diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 15a2112ac2..07a6c55466 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1199,6 +1199,9 @@ class TestQuickSnapshot: (home / "config.yaml").write_text("model:\n provider: openrouter\n") (home / ".env").write_text("OPENROUTER_API_KEY=test-key-123\n") (home / "auth.json").write_text('{"providers": {}}\n') + (home / "channel_aliases.json").write_text( + '{"whatsapp": {"120363408391911677@g.us": "general"}}\n' + ) (home / "cron").mkdir() (home / "cron" / "jobs.json").write_text('{"jobs": []}\n') @@ -1241,6 +1244,13 @@ class TestQuickSnapshot: snap_id = create_quick_snapshot(hermes_home=hermes_home) assert (hermes_home / "state-snapshots" / snap_id / "cron" / "jobs.json").exists() + def test_copies_channel_aliases(self, hermes_home): + from hermes_cli.backup import create_quick_snapshot + snap_id = create_quick_snapshot(hermes_home=hermes_home) + copied = hermes_home / "state-snapshots" / snap_id / "channel_aliases.json" + assert copied.exists() + assert "120363408391911677@g.us" in copied.read_text() + def test_missing_files_skipped(self, hermes_home): from hermes_cli.backup import create_quick_snapshot snap_id = create_quick_snapshot(hermes_home=hermes_home) diff --git a/tests/tools/test_send_message_target_parse.py b/tests/tools/test_send_message_target_parse.py index c3ad24576f..6b12265800 100644 --- a/tests/tools/test_send_message_target_parse.py +++ b/tests/tools/test_send_message_target_parse.py @@ -1,10 +1,20 @@ -"""Parser-only tests for send_message targets. +"""Parser-only and lightweight routing tests for send_message targets. These stay separate from ``test_send_message_tool.py`` because that module skips wholesale when optional Telegram dependencies are not installed. """ -from tools.send_message_tool import _parse_target_ref +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from gateway.config import Platform +from tools.send_message_tool import _parse_target_ref, send_message_tool + + +def _run_async_immediately(coro): + return asyncio.run(coro) def test_photon_e164_target_is_explicit() -> None: @@ -18,3 +28,65 @@ def test_photon_e164_target_is_explicit() -> None: def test_e164_target_still_requires_phone_platform() -> None: assert _parse_target_ref("matrix", "+15551234567")[2] is False + +def test_whatsapp_group_jid_target_is_explicit() -> None: + chat_id, thread_id, is_explicit = _parse_target_ref( + "whatsapp", "120363408391911677@g.us" + ) + + assert chat_id == "120363408391911677@g.us" + assert thread_id is None + assert is_explicit is True + + +def test_whatsapp_native_jids_are_explicit() -> None: + assert _parse_target_ref("whatsapp", "19255551234@s.whatsapp.net")[2] is True + assert _parse_target_ref("whatsapp", "149606612619433@lid")[2] is True + assert _parse_target_ref("whatsapp", "status@broadcast")[2] is True + assert _parse_target_ref("whatsapp", "120363000000000000@newsletter")[2] is True + + +def test_whatsapp_jid_suffix_only_matches_whatsapp() -> None: + assert _parse_target_ref("telegram", "120363408391911677@g.us")[2] is False + assert _parse_target_ref("signal", "149606612619433@lid")[2] is False + + +def test_whatsapp_friendly_name_still_uses_directory_resolution() -> None: + assert _parse_target_ref("whatsapp", "general")[2] is False + + +def test_send_message_routes_whatsapp_group_jid_without_home_fallback() -> None: + whatsapp_cfg = SimpleNamespace(enabled=True, token=None, extra={"api_url": "http://bridge"}) + config = SimpleNamespace( + platforms={Platform.WHATSAPP: whatsapp_cfg}, + get_home_channel=lambda _platform: SimpleNamespace(chat_id="15551234567@s.whatsapp.net"), + ) + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("gateway.channel_directory.resolve_channel_name", side_effect=AssertionError("raw JID should not resolve via directory")), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "whatsapp:120363408391911677@g.us", + "message": "hello group", + } + ) + ) + + assert result["success"] is True + assert "note" not in result + send_mock.assert_awaited_once_with( + Platform.WHATSAPP, + whatsapp_cfg, + "120363408391911677@g.us", + "hello group", + thread_id=None, + media_files=[], + force_document=False, + ) + From 975b9f0a5426858c3ae7f0d4e54701c08824bd09 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 06:14:57 -0700 Subject: [PATCH 30/92] docs: recommend standard installer for development (#46646) --- CONTRIBUTING.md | 50 +++++++++++++++--- README.md | 16 +++--- README.zh-CN.md | 14 ++--- website/docs/developer-guide/contributing.md | 52 ++++++++++++++++--- .../current/developer-guide/contributing.md | 41 ++++++++++++--- 5 files changed, 141 insertions(+), 32 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f77932bf1f..1a70116548 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,7 +78,41 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr | **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | | **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | -### Clone and install +### Install with the standard installer + +For most contributors, the best development bootstrap is the same path users +take: run the standard installer, then work inside the repository it cloned. +The installer creates the Hermes venv, wires the `hermes` command, stamps the +install method for `hermes update`, and clones the full git project into +`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your +development environment on the same layout the CLI, updater, lazy dependency +installer, gateway, and docs assume. + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" + +# Add dev/test extras on top of the standard install. +uv pip install -e ".[all,dev]" + +# Optional: browser tools / docs site dependencies. +npm install +``` + +After that, create branches and run tests from that checkout: + +```bash +git checkout -b fix/description +scripts/run_tests.sh +``` + +### Manual clone fallback + +Use this only if you intentionally do not want Hermes' managed install layout +(for example, a throwaway clone inside a container or CI job). If you install +this way, make sure you run the `hermes` entrypoint from this venv; running the +system `python3 -m hermes_cli.main` can pick up unrelated system Python +packages. ```bash git clone https://github.com/NousResearch/hermes-agent.git @@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env ### Run ```bash -# Symlink for global access -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes - -# Verify +# The standard installer already put `hermes` on PATH. hermes doctor hermes chat -q "Hello" ``` +If you used the manual clone fallback, run `./hermes` from the checkout or +symlink this clone's venv explicitly: + +```bash +mkdir -p ~/.local/bin +ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes +``` + ### Run tests ```bash diff --git a/README.md b/README.md index b65a11baf8..5fb4e80082 100644 --- a/README.md +++ b/README.md @@ -181,16 +181,20 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process. -Quick start for contributors — clone and go with `setup-hermes.sh`: +Quick start for contributors — use the standard installer, then work from the +full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually +`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the +managed venv, lazy dependencies, gateway, and docs tooling. ```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent -./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes -./hermes # auto-detects the venv, no need to `source` first +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" +uv pip install -e ".[all,dev]" +scripts/run_tests.sh ``` -Manual path (equivalent to the above): +Manual clone fallback (for throwaway clones/CI where you intentionally do not +want the managed install layout): ```bash curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/README.zh-CN.md b/README.zh-CN.md index 59b1268f81..2453739f91 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突 欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。 -贡献者快速开始——克隆并使用 `setup-hermes.sh`: +贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发: +`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配 +`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。 ```bash -git clone https://github.com/NousResearch/hermes-agent.git -cd hermes-agent -./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes -./hermes # 自动检测 venv,无需先 source +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" +uv pip install -e ".[all,dev]" +scripts/run_tests.sh ``` -手动安装(等效于上述命令): +手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时): ```bash curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index c69f45263b..3661f4359f 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -38,7 +38,41 @@ We value contributions in this order: | **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | | **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | -### Clone and Install +### Install with the standard installer + +For most contributors, the best development bootstrap is the same path users +take: run the standard installer, then work inside the repository it cloned. +The installer creates the Hermes venv, wires the `hermes` command, stamps the +install method for `hermes update`, and clones the full git project into +`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your +development environment on the same layout the CLI, updater, lazy dependency +installer, gateway, and docs assume. + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" + +# Add dev/test extras on top of the standard install. +uv pip install -e ".[all,dev]" + +# Optional: browser tools / docs site dependencies. +npm install +``` + +After that, create branches and run tests from that checkout: + +```bash +git checkout -b fix/description +scripts/run_tests.sh +``` + +### Manual clone fallback + +Use this only if you intentionally do not want Hermes' managed install layout +(for example, a throwaway clone inside a container or CI job). If you install +this way, make sure you run the `hermes` entrypoint from this venv; running the +system `python3 -m hermes_cli.main` can pick up unrelated system Python +packages. ```bash git clone https://github.com/NousResearch/hermes-agent.git @@ -69,19 +103,23 @@ echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env ### Run ```bash -# Symlink for global access -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes - -# Verify +# The standard installer already put `hermes` on PATH. hermes doctor hermes chat -q "Hello" ``` +If you used the manual clone fallback, run `./hermes` from the checkout or +symlink this clone's venv explicitly: + +```bash +mkdir -p ~/.local/bin +ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes +``` + ### Run Tests ```bash -pytest tests/ -v +scripts/run_tests.sh ``` ## Code Style diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md index 8c5f8591a8..fa347a5133 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/contributing.md @@ -38,7 +38,31 @@ description: "如何为 Hermes Agent 做贡献 — 开发环境配置、代码 | **uv** | 高速 Python 包管理器([安装](https://docs.astral.sh/uv/)) | | **Node.js 20+** | 可选 — 浏览器工具和 WhatsApp bridge 需要(与根目录 `package.json` engines 字段一致) | -### 克隆与安装 +### 使用标准安装器 + +对大多数贡献者来说,最好的开发启动方式和用户安装方式相同:运行标准安装器,然后在它克隆出的仓库里开发。安装器会创建 Hermes venv、配置 `hermes` 命令、为 `hermes update` 写入安装方式标记,并把完整 git 项目克隆到 `$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这样你的开发环境会和 CLI、updater、lazy dependency installer、gateway、docs 默认假设的布局一致。 + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent" + +# 在标准安装基础上添加开发/测试 extras。 +uv pip install -e ".[all,dev]" + +# 可选:浏览器工具 / docs site dependencies。 +npm install +``` + +之后从这个 checkout 创建分支并运行测试: + +```bash +git checkout -b fix/description +scripts/run_tests.sh +``` + +### 手动克隆备用路径 + +只有在你明确不想使用 Hermes managed install layout 时才使用这种方式(例如容器或 CI job 里的临时 clone)。如果这样安装,请确保运行的是这个 venv 里的 `hermes` entrypoint;运行系统 `python3 -m hermes_cli.main` 可能会加载无关的系统 Python 包。 ```bash git clone https://github.com/NousResearch/hermes-agent.git @@ -69,19 +93,22 @@ echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env ### 运行 ```bash -# 创建全局访问的符号链接 -mkdir -p ~/.local/bin -ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes - -# 验证 +# 标准安装器已经把 `hermes` 放到了 PATH 上。 hermes doctor hermes chat -q "Hello" ``` +如果你使用了手动克隆备用路径,可以在 checkout 中运行 `./hermes`,或显式把这个 clone 的 venv 链接到 PATH: + +```bash +mkdir -p ~/.local/bin +ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes +``` + ### 运行测试 ```bash -pytest tests/ -v +scripts/run_tests.sh ``` ## 代码风格 From 92a456f711ebbc7dba083094f7fc91f1dfd54904 Mon Sep 17 00:00:00 2001 From: FT_IOxCS <237263164+ft-ioxcs@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:55:58 -0700 Subject: [PATCH 31/92] fix(cli,deps): clear esbuild audit loop Upgrade the Vite/esbuild surfaces that kept web, ui-tui, and the bootstrap installer on vulnerable esbuild versions, regenerate the root lockfile, and preserve intentional package+lock dependency edits during update lockfile cleanup. --- apps/bootstrap-installer/package.json | 6 +- hermes_cli/main.py | 6 + package-lock.json | 9596 +++++++-------------- scripts/release.py | 1 + tests/hermes_cli/test_update_autostash.py | 57 + ui-tui/package.json | 4 +- ui-tui/packages/hermes-ink/package.json | 2 +- web/package.json | 4 +- 8 files changed, 3048 insertions(+), 6628 deletions(-) diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index 9b3dc46a4a..4638a8c905 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@nous-research/ui": "0.16.0", - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/vite": "^4.2.4", "@tailwindcss/typography": "^0.5.19", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", @@ -40,8 +40,8 @@ "@tauri-apps/cli": "^2.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^6.0.3", - "vite": "^7.3.1" + "vite": "^8.0.16" } } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b08b6acb3f..f70e6c6201 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8270,10 +8270,16 @@ def _discard_lockfile_churn(git_cmd, repo_root): ) if diff.returncode != 0: return + dirty_package_dirs = { + Path(line.strip()).parent + for line in diff.stdout.splitlines() + if line.strip().endswith("package.json") + } dirty = [ line.strip() for line in diff.stdout.splitlines() if line.strip().endswith("package-lock.json") + and Path(line.strip()).parent not in dirty_package_dirs ] if not dirty: return diff --git a/package-lock.json b/package-lock.json index 97e35d7ab5..0d8a5f51ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "dependencies": { "@nous-research/ui": "0.16.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/vite": "^4.2.4", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", @@ -52,23 +52,9 @@ "@tauri-apps/cli": "^2.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^6.0.3", - "vite": "^7.3.1" - } - }, - "apps/bootstrap-installer/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" + "vite": "^8.0.16" } }, "apps/desktop": { @@ -166,9 +152,10 @@ } }, "apps/desktop/node_modules/@nous-research/ui": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.0.tgz", - "integrity": "sha512-c07lfMdEv/KL6lYC6mfap1CcmIPbvhCZu1supnFaIIrlUaab8gVNDYl8wMMjNRdYOVxxXKisU48yyfe5qvlwqg==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.2.tgz", + "integrity": "sha512-iuav0o8UCpUDkleEF2JTNpC9SMwJxrsOL9bewTS+7eUcwHSD5Bk4Al6XX66ceIXyEWRkVDgODmpeuGOA5W2yCw==", + "license": "MIT", "dependencies": { "@nanostores/react": "^1.0.0", "class-variance-authority": "^0.7.1", @@ -207,333 +194,6 @@ } } }, - "apps/desktop/node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "apps/desktop/node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "apps/desktop/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "apps/desktop/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "apps/desktop/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "apps/desktop/node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "apps/desktop/node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "5.6.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.4", - "supports-color": "10.2.2", - "tree-kill": "1.2.2", - "yargs": "18.0.0" - }, - "bin": { - "conc": "dist/bin/index.js", - "concurrently": "dist/bin/index.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "apps/desktop/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "apps/desktop/node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "apps/desktop/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "apps/desktop/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "apps/desktop/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "apps/desktop/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "apps/desktop/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "apps/desktop/node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "apps/desktop/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "apps/desktop/node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "apps/desktop/node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, "apps/shared": { "name": "@hermes/shared", "version": "0.0.0", @@ -541,58 +201,17 @@ "typescript": "^6.0.3" } }, - "apps/shared/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", - "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "is-fullwidth-code-point": "^4.0.0" }, "engines": { - "node": ">=18" - } - }, - "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=14.13.1" } }, "node_modules/@antfu/install-pkg": { @@ -768,15 +387,15 @@ } }, "node_modules/@assistant-ui/store": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.9.tgz", - "integrity": "sha512-EDd6yCfirb2OsAKoTo7HeMtqPG+1cqVlNXOzUsho35ZF3O1XQ2CyEY4iUbdhj3HfmWeZo7rmfhvbaYQVEqAfeA==", + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.18.tgz", + "integrity": "sha512-5MiZXAXjsZuH3ZVEemuiD5L8wq/pXax8lSlaIsdTPEkDZDFupsiDwuOeum+h+ctX8H8oKgkCpN4iPUIiiLKuVg==", "license": "MIT", "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { - "@assistant-ui/tap": "^0.5.10", + "@assistant-ui/tap": "^0.9.0", "@types/react": "*", "react": "^18 || ^19" }, @@ -787,9 +406,9 @@ } }, "node_modules/@assistant-ui/tap": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.5.10.tgz", - "integrity": "sha512-sBHTf+q1geRyu5l4gJJp2hk6ZxwhHZHj39ixjC9ARADuIYedYv1B8bCNS82eTC/COpD1xe86mzvT/+HwIsO9WA==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.5.14.tgz", + "integrity": "sha512-SAy0ip8nKo72U8K9MuU7gYUR4tzoIi6k+HAQgev3zA/sWN7hr/QDDUTblrn5QB9Y/yycRiq8s98WD1vnDy8WMQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -824,13 +443,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -839,9 +458,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -849,21 +468,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -890,14 +509,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -907,14 +526,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -934,9 +553,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -944,29 +563,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -975,20 +594,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -996,9 +605,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -1006,9 +615,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -1016,27 +625,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1045,75 +654,43 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1121,14 +698,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1186,9 +763,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -1210,9 +787,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", + "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", "dev": true, "funding": [ { @@ -1227,7 +804,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -1261,9 +838,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -1305,28 +882,11 @@ "node": ">=20.19.0" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, - "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, @@ -1338,6 +898,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1352,6 +913,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" @@ -1365,6 +927,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, @@ -1372,6 +935,16 @@ "react": ">=16.8.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1398,9 +971,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1418,28 +991,6 @@ "node": ">= 6" } }, - "node_modules/@electron/asar/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@electron/asar/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1468,6 +1019,39 @@ "electron-fuses": "dist/bin.js" } }, + "node_modules/@electron/fuses/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@electron/fuses/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@electron/fuses/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -1484,71 +1068,49 @@ "node": ">=10" } }, + "node_modules/@electron/fuses/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/@electron/get/node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, + "optional": true, "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" + "node": ">=20.18.1" } }, "node_modules/@electron/notarize": { @@ -1638,19 +1200,6 @@ "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/node-abi": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.29.0.tgz", - "integrity": "sha512-bGc7hHz6lrdpMqH3XqfiHc5PKzEhjgUj6OLpTXynkLi9JZKyMByI/tdpm4Liu6O2BjtE1lakBWXjOQS1EnSQLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.6.3" - }, - "engines": { - "node": ">=22.12.0" - } - }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -1678,9 +1227,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -1688,9 +1237,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1718,6 +1267,76 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1726,9 +1345,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1737,14 +1356,15 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1753,14 +1373,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1769,14 +1390,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1785,14 +1407,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1801,14 +1424,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1817,14 +1441,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1833,14 +1458,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1849,14 +1475,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1865,14 +1492,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1881,14 +1509,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1897,14 +1526,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1913,14 +1543,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1929,14 +1560,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1945,14 +1577,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1961,14 +1594,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1977,14 +1611,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1993,14 +1628,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -2009,14 +1645,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -2025,14 +1662,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -2041,14 +1679,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -2057,14 +1696,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -2073,14 +1713,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -2089,14 +1730,15 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -2105,14 +1747,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -2121,14 +1764,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2137,6 +1781,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -2193,9 +1838,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2266,6 +1911,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2274,9 +1936,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2307,6 +1969,13 @@ "node": ">= 4" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2320,19 +1989,6 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -2371,9 +2027,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -2461,9 +2117,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", - "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2565,14 +2221,14 @@ "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "mlly": "^1.8.2" + "import-meta-resolve": "^4.2.0" } }, "node_modules/@icons-pack/react-simple-icons": { @@ -2730,14 +2386,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", - "dev": true, + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -2748,6 +2403,19 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nous-research/ui": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.16.0.tgz", @@ -2807,34 +2475,85 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", - "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.9.tgz", + "integrity": "sha512-5W9KzJz/3DeYbGJHbZv8Q6AkxMOKUmALfc+PRg9dWwJZMk6zD37Sz8sZrF7UD6CBkiJvn7dNeRzn5G7XiCMyig==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -2852,20 +2571,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", + "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.13", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -2882,74 +2601,18 @@ } } }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", - "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.16.tgz", + "integrity": "sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dialog": "1.1.16", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -2966,69 +2629,13 @@ } } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", + "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3045,54 +2652,13 @@ } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", - "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.9.tgz", + "integrity": "sha512-Xy+Dpxt/5n9rVTdPrNFmf8GwG1NlT1pzCF/z1MgOGZMLZWdWl+km+ZRWGQAPEhbkzSwYEsfYmTca8NhUtVxqnw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3109,58 +2675,17 @@ } } }, - "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-aspect-ratio/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-avatar": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", - "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.12.tgz", + "integrity": "sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3177,76 +2702,20 @@ } } }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.4.tgz", + "integrity": "sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3263,76 +2732,20 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", + "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3349,72 +2762,16 @@ } } }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", + "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -3431,66 +2788,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3503,9 +2804,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", - "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3518,17 +2819,16 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", - "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.0.tgz", + "integrity": "sha512-d7CouXhAW+CGmFOqmB+IEvd3E9GcaqfgvfjCc3hfulp2pkaUCEVEGa0SN5nNWYA+IvQ6g1Pt+S5dpNn1AoY9hg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3545,82 +2845,26 @@ } } }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", + "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -3637,66 +2881,10 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3709,16 +2897,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3735,60 +2923,19 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.17.tgz", + "integrity": "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3805,66 +2952,10 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -3877,14 +2968,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -3901,59 +2992,18 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-form": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", - "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.9.tgz", + "integrity": "sha512-eTPyThIKDacJ3mJDvYwf/PSmsEYlOyA2Qcb+aGyWwYv+P5w57VPUkMVA2XJ9z0Du2KBY1HoHQzhPV9iYL/r4hg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-label": "2.1.9", + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -3970,77 +3020,21 @@ } } }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", - "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.16.tgz", + "integrity": "sha512-hAileDBtd6CX7nlZOarOnISQ6PP4q0e16BX51ulzdZ+7IzjL0sDTVpFdmSYrIjw6zVNsfQBao5gG6AWr3qwfvA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4057,69 +3051,13 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4132,12 +3070,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.9.tgz", + "integrity": "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -4154,71 +3092,30 @@ } } }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.17.tgz", + "integrity": "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -4235,78 +3132,22 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", - "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.17.tgz", + "integrity": "sha512-AKtZ4O782yO7qwIyq73WpulYt1IHhQ0htDb6wNcxzxnSDCcSWMVBiU9ycpcA90XzQO4IVIxIErtak6Kg/Vt0rQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4323,82 +3164,26 @@ } } }, - "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", - "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.15.tgz", + "integrity": "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -4415,80 +3200,24 @@ } } }, - "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", - "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.9.tgz", + "integrity": "sha512-fvCzA9hm7yN5xxTPJIi4VhSmH5gv+76ILsxguBK3cm3icD5BR4vW7POQmu8Zio0yh91uuouG/Kang40IbMkaSQ==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4505,76 +3234,20 @@ } } }, - "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", - "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.4.tgz", + "integrity": "sha512-qoDSkObZ9faJlsjlwyBH6ia7kq9vaJ2QwWTowT3nQpzPvUTAKesmWuGJYpd91HIoJqS+5ZPXy5uFPp+HlwdaAg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-is-hydrated": "0.1.0" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4591,83 +3264,27 @@ } } }, - "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", + "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -4684,78 +3301,22 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", + "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4772,70 +3333,14 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4852,55 +3357,13 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -4918,12 +3381,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -4941,13 +3404,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", - "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.9.tgz", + "integrity": "sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -4964,78 +3427,22 @@ } } }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", - "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.0.tgz", + "integrity": "sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -5052,77 +3459,21 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", + "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5139,77 +3490,21 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", - "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", + "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -5226,89 +3521,34 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", - "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.0.tgz", + "integrity": "sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3", + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5", "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", @@ -5325,69 +3565,13 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", - "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.9.tgz", + "integrity": "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -5404,64 +3588,23 @@ } } }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.0.tgz", + "integrity": "sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -5478,69 +3621,13 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -5553,18 +3640,18 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", - "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.0.tgz", + "integrity": "sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -5581,76 +3668,20 @@ } } }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", + "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5667,80 +3698,24 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", - "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.16.tgz", + "integrity": "sha512-WUymDDiN2DpoGudRN1aW4wF5O3BNQjZZO/5nngPoNiEVqjyOzirvZZNO0R6dC1ifucSINVaSv8JX1aq47VGgiA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -5757,71 +3732,15 @@ } } }, - "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.11.tgz", + "integrity": "sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5839,18 +3758,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.12.tgz", + "integrity": "sha512-TEgECgJaWGAHJJZGzNNEYTNBdIXqX7LchANycpyP7DkfjmuiSN7ISt1k/ZRGVJgVJonsgP4vwaiKMn5utrcwWQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-toggle": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -5867,116 +3786,19 @@ } } }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", - "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.12.tgz", + "integrity": "sha512-4wHtJVdIgqMmEwUvxA0BYg/2JMRbt0L3+8UD8Ml/nhKkfXtiZcM8u/S15gQ5xj9YEd/0qlrm5bE805LsjQ+J8A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-toggle-group": "1.1.11" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-separator": "1.1.9", + "@radix-ui/react-toggle-group": "1.1.12" }, "peerDependencies": { "@types/react": "*", @@ -5993,80 +3815,24 @@ } } }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.9.tgz", + "integrity": "sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -6083,66 +3849,10 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6155,13 +3865,13 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6174,12 +3884,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6192,12 +3902,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6210,13 +3920,10 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", - "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", + "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.5.0" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -6228,9 +3935,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6243,9 +3950,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -6258,12 +3965,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.1" + "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6276,12 +3983,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -6294,12 +4001,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", + "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -6316,67 +4023,29 @@ } } }, - "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", "license": "MIT" }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", - "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==" + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", + "license": "MIT" }, "node_modules/@react-dnd/invariant": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", - "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", + "license": "MIT" }, "node_modules/@react-dnd/shallowequal": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", - "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", + "license": "MIT" }, "node_modules/@react-three/fiber": { "version": "9.6.1", @@ -6426,38 +4095,13 @@ } } }, - "node_modules/@react-three/fiber/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6468,13 +4112,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6485,13 +4128,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6502,13 +4144,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6519,13 +4160,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6536,13 +4176,12 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6553,13 +4192,12 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6570,13 +4208,12 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6587,13 +4224,12 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6604,13 +4240,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6621,13 +4256,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6638,13 +4272,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6655,13 +4288,12 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6674,13 +4306,12 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6691,13 +4322,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6708,345 +4338,19 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@shikijs/core": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", - "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.2.0.tgz", + "integrity": "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/primitive": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" @@ -7056,26 +4360,26 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", - "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.2.0.tgz", + "integrity": "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", - "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.2.0.tgz", + "integrity": "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -7083,24 +4387,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", - "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.2.0.tgz", + "integrity": "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.2.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", - "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.2.0.tgz", + "integrity": "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -7109,21 +4413,21 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", - "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz", + "integrity": "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.2" + "@shikijs/types": "4.2.0" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", - "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.2.0.tgz", + "integrity": "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -7284,9 +4588,9 @@ } }, "node_modules/@tabler/icons": { - "version": "3.41.1", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.41.1.tgz", - "integrity": "sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", + "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", "license": "MIT", "funding": { "type": "github", @@ -7294,12 +4598,12 @@ } }, "node_modules/@tabler/icons-react": { - "version": "3.41.1", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.41.1.tgz", - "integrity": "sha512-kUgweE+DJtAlMZVIns1FTDdcbpRVnkK7ZpUOXmoxy3JAF0rSHj0TcP4VHF14+gMJGnF+psH2Zt26BLT6owetBA==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz", + "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==", "license": "MIT", "dependencies": { - "@tabler/icons": "3.41.1" + "@tabler/icons": "3.44.0" }, "funding": { "type": "github", @@ -7310,47 +4614,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -7364,9 +4668,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -7380,9 +4684,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -7396,9 +4700,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -7412,9 +4716,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -7428,9 +4732,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], @@ -7444,9 +4748,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], @@ -7460,9 +4764,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], @@ -7476,9 +4780,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], @@ -7492,9 +4796,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -7509,11 +4813,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -7521,17 +4825,17 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + "version": "1.10.0", "inBundle": true, "license": "MIT", "optional": true, @@ -7540,7 +4844,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + "version": "1.2.1", "inBundle": true, "license": "MIT", "optional": true, @@ -7549,22 +4853,24 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + "version": "1.1.4", "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "version": "0.10.2", "inBundle": true, "license": "MIT", "optional": true, @@ -7579,9 +4885,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -7595,9 +4901,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -7611,34 +4917,35 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", - "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "license": "MIT", "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tanstack/query-core": { - "version": "5.100.8", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz", - "integrity": "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", "license": "MIT", "funding": { "type": "github", @@ -7646,12 +4953,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.8", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.8.tgz", - "integrity": "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==", + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.8" + "@tanstack/query-core": "5.101.0" }, "funding": { "type": "github", @@ -7662,11 +4969,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.24", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", - "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.14.0" + "@tanstack/virtual-core": "3.17.0" }, "funding": { "type": "github", @@ -7678,9 +4986,10 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", - "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" @@ -7998,10 +5307,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -8015,51 +5323,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -8354,9 +5617,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -8439,25 +5702,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "undici-types": "~7.18.0" } }, "node_modules/@types/qrcode": { @@ -8471,9 +5722,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -8521,43 +5772,24 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -8570,22 +5802,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -8601,14 +5833,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -8623,14 +5855,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8641,9 +5873,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -8658,15 +5890,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -8683,9 +5915,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -8697,16 +5929,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8725,16 +5957,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8749,13 +5981,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8780,9 +6012,9 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, "node_modules/@upsetjs/venn.js": { @@ -8814,44 +6046,42 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, - "node_modules/@vitejs/plugin-react/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -8860,13 +6090,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -8887,9 +6117,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -8900,13 +6130,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -8914,14 +6144,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -8930,9 +6160,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -8940,13 +6170,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8957,7 +6187,8 @@ "node_modules/@vscode/codicons": { "version": "0.0.45", "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45.tgz", - "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==" + "integrity": "sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==", + "license": "CC-BY-4.0" }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", @@ -8972,38 +6203,36 @@ "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==" + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" }, "node_modules/@xterm/addon-unicode11": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", - "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==" + "integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==", + "license": "MIT" }, "node_modules/@xterm/addon-web-links": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==" + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" }, "node_modules/@xterm/addon-webgl": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", - "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==" + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", "workspaces": [ "addons/*" ] }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -9015,9 +6244,10 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -9057,32 +6287,22 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -9108,54 +6328,47 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.12", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", - "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.8.1.tgz", - "integrity": "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", + "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.8.1", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -9163,7 +6376,8 @@ "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", @@ -9171,14 +6385,15 @@ "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.8.1", - "electron-builder-squirrel-windows": "26.8.1" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -9244,6 +6459,16 @@ "node": ">=8" } }, + "node_modules/app-builder-lib/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/app-builder-lib/node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -9264,6 +6489,19 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/app-builder-lib/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -9457,15 +6695,19 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=0.8" + "node": ">=12.0.0" } }, "node_modules/assertion-error": { @@ -9488,34 +6730,35 @@ } }, "node_modules/assistant-cloud": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz", - "integrity": "sha512-BGfVnx7YFN5xtB/kbrgGxRI0TfSWq4yxB3MwYn6RDPlv4JvdtPupvDC1Y6An0EhAe42Z0AYtSmDSsR6p6eeBng==", + "version": "0.1.33", + "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.33.tgz", + "integrity": "sha512-lvvy2FoTymfAcTSVC5RzIJE9Pt3+0NT7teCeo7hCH22OJmcRih+sT9UejAKjvpVJvPyy2vR8HdJnHc/UWYUKZg==", "license": "MIT", "dependencies": { - "assistant-stream": "^0.3.12" + "assistant-stream": "^0.3.23" } }, "node_modules/assistant-stream": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.3.12.tgz", - "integrity": "sha512-ZdfdyeZjeffkUfZLGTre9rW+9nBSPi6U5tYvchYjAxVuyiYVf5H9vw7SxegTq5bAMT9IitpDOaYMZGWFoMtaow==", + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/assistant-stream/-/assistant-stream-0.3.23.tgz", + "integrity": "sha512-DTiOaRiaAA0bhbJ4sAyq0JYQ0rWPxL8rCK03KrowCiMGIoAmGtAwvAwzBruI+KlLTMjXqvWa4L0SKfAE/OtkHQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "nanoid": "^5.1.9", + "nanoid": "^5.1.11", "secure-json-parse": "^4.1.0" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=8" + }, + "peerDependencies": { + "ioredis": "^5.10.1", + "redis": "^5.12.1" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "redis": { + "optional": true + } } }, "node_modules/async": { @@ -9599,18 +6842,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -9652,9 +6930,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", - "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9679,6 +6957,13 @@ "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", "license": "MIT" }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -9736,10 +7021,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -9755,20 +7039,9 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -9779,16 +7052,14 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", - "integrity": "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.5.1", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", @@ -9801,12 +7072,15 @@ "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/builder-util-runtime": { - "version": "9.5.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", - "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "dev": true, "license": "MIT", "dependencies": { @@ -9817,6 +7091,62 @@ "node": ">=12.0.0" } }, + "node_modules/builder-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/builder-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/builder-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -9916,9 +7246,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -9957,35 +7287,17 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -10026,6 +7338,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/chromium-pickle-js": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", @@ -10089,36 +7411,52 @@ } }, "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "license": "MIT", - "optional": true, "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/clone-response": { @@ -10134,16 +7472,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/clone-response/node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -10228,6 +7556,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/compare-version": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", @@ -10245,11 +7582,30 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } }, "node_modules/convert-source-map": { "version": "2.0.0", @@ -10281,12 +7637,11 @@ } }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/cose-base": { "version": "1.0.3", @@ -10297,16 +7652,14 @@ "layout-base": "^1.0.0" } }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -10407,6 +7760,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", "bin": { "cssesc": "bin/cssesc" }, @@ -10421,9 +7775,9 @@ "license": "MIT" }, "node_modules/cytoscape": { - "version": "3.33.3", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", - "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "license": "MIT", "engines": { "node": ">=0.10" @@ -10647,18 +8001,6 @@ "node": ">= 10" } }, - "node_modules/d3-dsv/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -11009,9 +8351,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "license": "MIT" }, "node_modules/debug": { @@ -11076,6 +8418,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -11227,9 +8582,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -11251,80 +8606,29 @@ } }, "node_modules/dmg-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", - "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" } }, "node_modules/dnd-core": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", "dependencies": { "@react-dnd/asap": "^4.0.0", "@react-dnd/invariant": "^2.0.0", "redux": "^4.1.1" } }, - "node_modules/dnd-core/node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -11399,9 +8703,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -11465,6 +8769,16 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -11482,37 +8796,37 @@ } }, "node_modules/electron": { - "version": "40.9.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.9.3.tgz", - "integrity": "sha512-rDcJOT6BBE689Ada+4jD3rVr05pMv9MZOgT0x/rIMVDF9c4ttx4RTb6lVARTyxZC7uqpirttCtcli1eg1DX5qg==", + "version": "40.10.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.3.tgz", + "integrity": "sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.8.1.tgz", - "integrity": "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.8.1", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -11527,28 +8841,182 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.8.1.tgz", - "integrity": "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.8.1", - "builder-util": "26.8.1", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, + "node_modules/electron-builder/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-builder/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/electron-builder/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/electron-publish": { - "version": "26.8.1", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.8.1.tgz", - "integrity": "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "26.8.1", - "builder-util-runtime": "9.5.1", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", @@ -11556,23 +9024,56 @@ "mime": "^2.5.2" } }, - "node_modules/electron-publish/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "node_modules/electron-publish/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "bin": { - "mime": "cli.js" + "dependencies": { + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4.0.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/electron-publish/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/electron-publish/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/electron-to-chromium": { - "version": "1.5.335", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", - "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", "dev": true, "license": "ISC" }, @@ -11598,25 +9099,6 @@ "@electron/windows-sign": "^1.1.2" } }, - "node_modules/electron-winstaller/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/electron-winstaller/node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -11644,14 +9126,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/electron-winstaller/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/electron-winstaller/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -11664,9 +9138,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/end-of-stream": { @@ -11680,9 +9154,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -11693,26 +9167,28 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/environment": { @@ -11824,9 +9300,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11859,9 +9335,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -11919,9 +9395,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", "license": "MIT", "workspaces": [ "docs", @@ -11937,9 +9413,10 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -11949,32 +9426,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -12130,9 +9607,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -12147,9 +9624,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -12226,6 +9703,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -12234,9 +9744,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -12244,6 +9754,23 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -12267,6 +9794,13 @@ "node": ">= 4" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -12280,6 +9814,19 @@ "node": "*" } }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -12412,37 +9959,14 @@ "node": ">=0.10.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], + "node_modules/extend-shallow/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "license": "MIT", - "optional": true + "engines": { + "node": ">=0.10.0" + } }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -12464,15 +9988,22 @@ "dev": true, "license": "MIT" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fdir": { "version": "6.5.0", @@ -12534,9 +10065,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -12641,29 +10172,30 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, "node_modules/framer-motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", - "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", "dependencies": { - "motion-dom": "^12.38.0", - "motion-utils": "^12.36.0", + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -12730,18 +10262,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -12892,6 +10427,28 @@ "node": ">=0.10.0" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -12905,6 +10462,37 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -13092,9 +10680,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -13153,30 +10741,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-from-html/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/hast-util-from-html/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -13248,30 +10812,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/hast-util-raw/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/hast-util-sanitize": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", @@ -13431,6 +10971,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" } @@ -13438,7 +10979,8 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" }, "node_modules/hosted-git-info": { "version": "4.1.0", @@ -13586,22 +11128,16 @@ "node": ">= 14" } }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": "^8.11.2 || >=10" + "node": ">=0.10.0" } }, "node_modules/ieee754": { @@ -13628,6 +11164,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -13649,6 +11186,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -13756,88 +11303,19 @@ "react": ">=18" } }, - "node_modules/ink-text-input/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ink-text-input/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ink/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ink/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ink/node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "node_modules/ink/node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==", "license": "MIT", "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/ink/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/ink/node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -13853,52 +11331,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink/node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/ink/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" }, "node_modules/ink/node_modules/type-fest": { "version": "5.7.0", @@ -13915,40 +11352,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/ink/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -14097,13 +11500,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -14173,11 +11576,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, "engines": { "node": ">=0.10.0" } @@ -14209,12 +11631,15 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-generator-function": { @@ -14598,18 +12023,18 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, "node_modules/joi": { - "version": "18.1.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", - "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -14632,10 +12057,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -14685,16 +12120,52 @@ } } }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14716,9 +12187,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -14751,9 +12222,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -14780,9 +12251,9 @@ } }, "node_modules/katex": { - "version": "0.16.45", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", - "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -14795,15 +12266,6 @@ "katex": "cli.js" } }, - "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -14845,6 +12307,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", + "license": "MIT", "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -15612,7 +13075,8 @@ "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" }, "node_modules/merge-value": { "version": "1.0.0", @@ -15629,18 +13093,6 @@ "node": ">=0.10.0" } }, - "node_modules/merge-value/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mermaid": { "version": "11.15.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", @@ -16264,6 +13716,19 @@ ], "license": "MIT" }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -16297,16 +13762,13 @@ } }, "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, "node_modules/minimatch": { @@ -16371,18 +13833,6 @@ "node": ">=0.10.0" } }, - "node_modules/mixin-deep/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -16397,24 +13847,13 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", "license": "MIT", "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/motion": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", - "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", - "dependencies": { - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -16435,17 +13874,19 @@ } }, "node_modules/motion-dom": { - "version": "12.38.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", - "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", "dependencies": { - "motion-utils": "^12.36.0" + "motion-utils": "^12.39.0" } }, "node_modules/motion-utils": { - "version": "12.36.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", - "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==" + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -16503,13 +13944,24 @@ "node": ">=18" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "node_modules/node-abi": { + "version": "4.31.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", + "integrity": "sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==", "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "semver": "^7.6.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" }, "node_modules/node-api-version": { "version": "0.2.1", @@ -16551,9 +14003,9 @@ } }, "node_modules/node-gyp": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", - "integrity": "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { @@ -16575,6 +14027,16 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/node-gyp/node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -16585,16 +14047,6 @@ "node": ">=20" } }, - "node_modules/node-gyp/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -16611,6 +14063,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", @@ -16621,18 +14080,15 @@ "node-addon-api": "^7.1.0" } }, - "node_modules/node-pty/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -16771,15 +14227,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/once": { "version": "1.4.0", @@ -16961,13 +14420,12 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -17028,6 +14486,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, "license": "MIT" }, "node_modules/pe-library": { @@ -17045,13 +14504,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -17070,15 +14522,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/plist": { @@ -17096,16 +14568,6 @@ "node": ">=10.4.0" } }, - "node_modules/plist/node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0" - } - }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -17142,9 +14604,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -17161,7 +14623,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -17173,6 +14635,7 @@ "version": "6.0.10", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17199,6 +14662,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -17210,9 +14703,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -17263,6 +14756,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -17316,10 +14816,17 @@ "signal-exit": "^3.0.2" } }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -17357,6 +14864,26 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -17374,6 +14901,21 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/qrcode/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -17385,6 +14927,12 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/qrcode/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -17398,6 +14946,15 @@ "node": ">=8" } }, + "node_modules/qrcode/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/qrcode/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -17437,6 +14994,32 @@ "node": ">=8" } }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/qrcode/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -17506,66 +15089,66 @@ } }, "node_modules/radix-ui": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", - "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.5.0.tgz", + "integrity": "sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-accessible-icon": "1.1.7", - "@radix-ui/react-accordion": "1.2.12", - "@radix-ui/react-alert-dialog": "1.1.15", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-aspect-ratio": "1.1.7", - "@radix-ui/react-avatar": "1.1.10", - "@radix-ui/react-checkbox": "1.3.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-context-menu": "2.2.16", - "@radix-ui/react-dialog": "1.1.15", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-form": "0.1.8", - "@radix-ui/react-hover-card": "1.1.15", - "@radix-ui/react-label": "2.1.7", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-menubar": "1.1.16", - "@radix-ui/react-navigation-menu": "1.2.14", - "@radix-ui/react-one-time-password-field": "0.1.8", - "@radix-ui/react-password-toggle-field": "0.1.3", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-progress": "1.1.7", - "@radix-ui/react-radio-group": "1.3.8", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-scroll-area": "1.2.10", - "@radix-ui/react-select": "2.2.6", - "@radix-ui/react-separator": "1.1.7", - "@radix-ui/react-slider": "1.3.6", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toast": "1.2.15", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-toggle-group": "1.1.11", - "@radix-ui/react-toolbar": "1.1.11", - "@radix-ui/react-tooltip": "1.2.8", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-escape-keydown": "1.1.1", - "@radix-ui/react-use-is-hydrated": "0.1.0", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/react-visually-hidden": "1.2.3" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-accessible-icon": "1.1.9", + "@radix-ui/react-accordion": "1.2.13", + "@radix-ui/react-alert-dialog": "1.1.16", + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-aspect-ratio": "1.1.9", + "@radix-ui/react-avatar": "1.1.12", + "@radix-ui/react-checkbox": "1.3.4", + "@radix-ui/react-collapsible": "1.1.13", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-context-menu": "2.3.0", + "@radix-ui/react-dialog": "1.1.16", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dropdown-menu": "2.1.17", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-form": "0.1.9", + "@radix-ui/react-hover-card": "1.1.16", + "@radix-ui/react-label": "2.1.9", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-menubar": "1.1.17", + "@radix-ui/react-navigation-menu": "1.2.15", + "@radix-ui/react-one-time-password-field": "0.1.9", + "@radix-ui/react-password-toggle-field": "0.1.4", + "@radix-ui/react-popover": "1.1.16", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-progress": "1.1.9", + "@radix-ui/react-radio-group": "1.4.0", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-scroll-area": "1.2.11", + "@radix-ui/react-select": "2.3.0", + "@radix-ui/react-separator": "1.1.9", + "@radix-ui/react-slider": "1.4.0", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-switch": "1.3.0", + "@radix-ui/react-tabs": "1.1.14", + "@radix-ui/react-toast": "1.2.16", + "@radix-ui/react-toggle": "1.1.11", + "@radix-ui/react-toggle-group": "1.1.12", + "@radix-ui/react-toolbar": "1.1.12", + "@radix-ui/react-tooltip": "1.2.9", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-escape-keydown": "1.1.2", + "@radix-ui/react-use-is-hydrated": "0.1.1", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -17582,62 +15165,6 @@ } } }, - "node_modules/radix-ui/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/rcedit": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz", @@ -17653,18 +15180,19 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-arborist": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.5.0.tgz", - "integrity": "sha512-FdXOICSt7P2h+Pxin1ULN02b4qrXJznNcshgwwWVtuYMLWSJcD245PQ4HOSj/Lr2T1uEegmnEm5Lbns2hUUsqg==", + "version": "3.10.5", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.10.5.tgz", + "integrity": "sha512-gbxFTLb0vCGmFOcJ/ZY2/ymCIdG4U8ok6fApsSeUWhGxUXwgtLpbylKNbSpzhjovv1D3VRFTW3OiiRs+Coh1vg==", + "license": "MIT", "dependencies": { "react-dnd": "^14.0.3", "react-dnd-html5-backend": "^14.0.3", @@ -17677,10 +15205,16 @@ "react-dom": ">= 16.14" } }, + "node_modules/react-arborist/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/react-colorful": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", - "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz", + "integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==", "license": "MIT", "peerDependencies": { "react": ">=16.8.0", @@ -17691,6 +15225,7 @@ "version": "14.0.5", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", @@ -17720,20 +15255,21 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", "dependencies": { "dnd-core": "14.0.1" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.7" } }, "node_modules/react-dropzone": { @@ -17775,16 +15311,6 @@ "react": "^19.2.0" } }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -17955,6 +15481,7 @@ "version": "1.8.11", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" @@ -17980,10 +15507,37 @@ "read-binary-file-arch": "cli.js" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -18241,14 +15795,14 @@ } }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -18310,6 +15864,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -18335,63 +15895,6 @@ "rimraf": "bin.js" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -18418,14 +15921,13 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", - "dev": true, + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -18434,72 +15936,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/roughjs": { @@ -18550,6 +16001,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -18602,9 +16060,9 @@ } }, "node_modules/sanitize-html": { - "version": "2.17.4", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", - "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", + "version": "2.17.5", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz", + "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", @@ -18671,9 +16129,9 @@ "license": "BSD-3-Clause" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -18707,6 +16165,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -18783,6 +16255,15 @@ "node": ">=0.10.0" } }, + "node_modules/set-value/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -18806,18 +16287,31 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shiki": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", - "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz", + "integrity": "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/engine-javascript": "4.0.2", - "@shikijs/engine-oniguruma": "4.0.2", - "@shikijs/langs": "4.0.2", - "@shikijs/themes": "4.0.2", - "@shikijs/types": "4.0.2", + "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", + "@shikijs/engine-oniguruma": "4.2.0", + "@shikijs/langs": "4.2.0", + "@shikijs/themes": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -18826,15 +16320,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -18909,10 +16403,16 @@ "license": "ISC" }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/simple-update-notifier": { "version": "2.0.0", @@ -18928,31 +16428,34 @@ } }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "license": "MIT", - "optional": true, "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "license": "MIT", - "optional": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/source-map": { @@ -19020,18 +16523,6 @@ "node": ">=0.10.0" } }, - "node_modules/split-string/node_modules/is-extendable": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", - "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -19103,6 +16594,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/streamdown/-/streamdown-2.5.0.tgz", "integrity": "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==", + "license": "Apache-2.0", "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", @@ -19126,18 +16618,30 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/string.prototype.matchall": { @@ -19180,19 +16684,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -19202,16 +16707,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -19253,15 +16758,43 @@ } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.2.2" }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/style-to-js": { @@ -19301,6 +16834,19 @@ "node": ">= 8.0" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/supports-hyperlinks": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", @@ -19381,9 +16927,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "license": "MIT" }, "node_modules/tapable": { @@ -19400,9 +16946,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -19416,16 +16962,6 @@ "node": ">=18" } }, - "node_modules/tar/node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -19508,18 +17044,18 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -19543,22 +17079,22 @@ } }, "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.30" + "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", "dev": true, "license": "MIT" }, @@ -19662,9 +17198,9 @@ } }, "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", "license": "MIT", "engines": { "node": ">=6.10" @@ -19695,490 +17231,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "devOptional": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -20211,14 +17263,12 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "license": "(MIT OR CC0-1.0)", - "optional": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -20282,18 +17332,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -20303,12 +17353,11 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20318,16 +17367,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -20341,12 +17390,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "license": "MIT" - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -20367,19 +17410,18 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", + "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "devOptional": true, "license": "MIT" }, @@ -20521,6 +17563,35 @@ "node": ">= 10.0.0" } }, + "node_modules/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -20712,16 +17783,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8n": { @@ -20730,22 +17801,6 @@ "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", "license": "MIT" }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -20789,17 +17844,16 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -20815,9 +17869,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -20830,15 +17885,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -20863,19 +17921,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -20903,12 +17961,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -20966,14 +18024,14 @@ } }, "node_modules/wait-on": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz", - "integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.15.0", - "joi": "^18.1.2", + "axios": "^1.16.0", + "joi": "^18.2.1", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" @@ -21025,6 +18083,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -21150,14 +18222,14 @@ "license": "ISC" }, "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -21203,49 +18275,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/widest-line/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/widest-line/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/widest-line/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -21257,23 +18286,39 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -21313,14 +18358,13 @@ } }, "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=4.0" + "node": ">=8.0" } }, "node_modules/xmlchars": { @@ -21348,43 +18392,49 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { @@ -21407,9 +18457,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", - "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -21429,9 +18479,9 @@ } }, "node_modules/zustand": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", - "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -21486,7 +18536,7 @@ "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", - "esbuild": "~0.27.0", + "esbuild": "^0.28.1", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", @@ -21494,632 +18544,11 @@ "eslint-plugin-unused-imports": "^4", "globals": "^16", "prettier": "^3", - "tsx": "^4.19.0", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.3" } }, - "ui-tui/node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.13.1" - } - }, - "ui-tui/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "ui-tui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "ui-tui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "ui-tui/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "ui-tui/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "ui-tui/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "ui-tui/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "ui-tui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "ui-tui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "ui-tui/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "ui-tui/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "ui-tui/node_modules/undici": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.26.0.tgz", - "integrity": "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==", - "engines": { - "node": ">=18.17" - } - }, - "ui-tui/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "ui-tui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "ui-tui/packages/hermes-ink": { "name": "@hermes/ink", "version": "0.0.1", @@ -22146,55 +18575,13 @@ "wrap-ansi": "^9.0.0" }, "devDependencies": { - "esbuild": "^0.25.0" + "esbuild": "^0.28.1" }, "peerDependencies": { "ink-text-input": ">=6.0.0", "react": ">=19.0.0" } }, - "ui-tui/packages/hermes-ink/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "web": { "version": "0.0.0", "dependencies": { @@ -22227,7 +18614,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -22235,7 +18622,7 @@ "three": "^0.180.0", "typescript": "^6.0.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^8.0.16" } }, "web/node_modules/@nous-research/ui": { @@ -22282,16 +18669,6 @@ } } }, - "web/node_modules/@types/node": { - "version": "24.13.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", - "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, "web/node_modules/globals": { "version": "17.6.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", @@ -22304,27 +18681,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "web/node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "web/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" } } } diff --git a/scripts/release.py b/scripts/release.py index 95d12106ff..6ab49a4095 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -85,6 +85,7 @@ AUTHOR_MAP = { "290859878+synapsesx@users.noreply.github.com": "synapsesx", "157689911+itsflownium@users.noreply.github.com": "itsflownium", "dirtyren@users.noreply.github.com": "dirtyren", + "237263164+ft-ioxcs@users.noreply.github.com": "ft-ioxcs", "tharushkadinujaya05@gmail.com": "0xneobyte", "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", "keiron@onehanded.com": "kmccammon", diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index a6db6c669d..be1a5f1acf 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -322,6 +322,63 @@ def test_stash_local_changes_if_needed_raises_when_stash_ref_missing(monkeypatch hermes_main._stash_local_changes_if_needed(["git"], Path(tmp_path)) +def test_discard_lockfile_churn_skips_lock_when_package_json_dirty(tmp_path): + """Intentional dependency edits update package.json and lockfile together.""" + import shutil + import subprocess + + if shutil.which("git") is None: + pytest.skip("git not available") + + def git(*args): + return subprocess.run( + ["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True + ) + + git("init", "-q") + git("config", "user.email", "t@example.com") + git("config", "user.name", "t") + (tmp_path / "package.json").write_text('{"dependencies":{"a":"1"}}\n') + (tmp_path / "package-lock.json").write_text('{"lock":"old"}\n') + git("add", "package.json", "package-lock.json") + git("commit", "-qm", "init") + + (tmp_path / "package.json").write_text('{"dependencies":{"a":"2"}}\n') + (tmp_path / "package-lock.json").write_text('{"lock":"new"}\n') + + hermes_main._discard_lockfile_churn(["git"], tmp_path) + + assert (tmp_path / "package-lock.json").read_text() == '{"lock":"new"}\n' + + +def test_discard_lockfile_churn_restores_lock_when_package_json_clean(tmp_path): + """Runtime npm lockfile rewrites are still discarded on managed updates.""" + import shutil + import subprocess + + if shutil.which("git") is None: + pytest.skip("git not available") + + def git(*args): + return subprocess.run( + ["git", *args], cwd=tmp_path, capture_output=True, text=True, check=True + ) + + git("init", "-q") + git("config", "user.email", "t@example.com") + git("config", "user.name", "t") + (tmp_path / "package.json").write_text('{"dependencies":{"a":"1"}}\n') + (tmp_path / "package-lock.json").write_text('{"lock":"old"}\n') + git("add", "package.json", "package-lock.json") + git("commit", "-qm", "init") + + (tmp_path / "package-lock.json").write_text('{"lock":"runtime-churn"}\n') + + hermes_main._discard_lockfile_churn(["git"], tmp_path) + + assert (tmp_path / "package-lock.json").read_text() == '{"lock":"old"}\n' + + # --------------------------------------------------------------------------- # Update uses .[all] with fallback to . # --------------------------------------------------------------------------- diff --git a/ui-tui/package.json b/ui-tui/package.json index c81ccc4e8d..d0a59798fb 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -31,7 +31,7 @@ "@types/react": "^19.2.14", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", - "esbuild": "~0.27.0", + "esbuild": "^0.28.1", "eslint": "^9", "eslint-plugin-perfectionist": "^5", "eslint-plugin-react": "^7", @@ -39,7 +39,7 @@ "eslint-plugin-unused-imports": "^4", "globals": "^16", "prettier": "^3", - "tsx": "^4.19.0", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.3" } diff --git a/ui-tui/packages/hermes-ink/package.json b/ui-tui/packages/hermes-ink/package.json index 8df3c02a4a..ab6728a7c9 100644 --- a/ui-tui/packages/hermes-ink/package.json +++ b/ui-tui/packages/hermes-ink/package.json @@ -49,6 +49,6 @@ "wrap-ansi": "^9.0.0" }, "devDependencies": { - "esbuild": "^0.25.0" + "esbuild": "^0.28.1" } } diff --git a/web/package.json b/web/package.json index de39ff26b9..665a780c71 100644 --- a/web/package.json +++ b/web/package.json @@ -40,7 +40,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react": "^6.0.2", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -48,6 +48,6 @@ "three": "^0.180.0", "typescript": "^6.0.3", "typescript-eslint": "^8.56.1", - "vite": "^7.3.1" + "vite": "^8.0.16" } } From 29c6985590043fc672a6c9a7cdb9a8695388d1ac Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 06:01:56 -0700 Subject: [PATCH 32/92] fix(nix): refresh npm deps hash --- nix/lib.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib.nix b/nix/lib.nix index f8914be90c..df5898004f 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-RLraluZYEWfg1cP4SFDlMo2qJ4eHWVkmQevMGThvxHA="; + npmDepsHash = "sha256-C7eu7WkT0z2XTey/2tnjg7vVBw9XhQMSDhFkUzT/+HI="; npmDeps = pkgs.fetchNpmDeps { inherit src; From e5b4cf7bea2876f761b269df5df34272300a9fae Mon Sep 17 00:00:00 2001 From: CiarasClaws <268233388+CiarasClaws@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:13:03 +0100 Subject: [PATCH 33/92] fix(cron): make jobs.json writes safe across processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes cron pause`/`resume`/`remove` run in their own CLI process (CLI → cronjob tool → pause_job → update_job → save_jobs), entirely separate from the gateway process that also writes jobs.json (mark_job_run, advance_next_run, due-fast-forward in get_due_jobs). The only synchronization was a module-level `threading.Lock`, which serializes writers *within a single process* but does nothing across processes — and update_job/pause_job/remove_job/create_job did not even take it. The result is a classic lost update: a `cron pause` issued while the gateway is live loads jobs.json, sets enabled=False, and saves; concurrently the gateway loads the same file and saves back its run-bookkeeping, clobbering the pause. The CLI prints "Paused" (it succeeded against its own in-memory copy) but the job stays enabled and keeps firing, with no error surfaced. The scheduler's `.tick.lock` flock can't be reused for this — it is held for the entire tick, including multi-minute agent runs, so a CLI mutation would block for minutes. Add `_jobs_lock()`: a short-held cross-process advisory file lock (fcntl/msvcrt flock on `/cron/.jobs.lock`) layered over the existing in-process lock, and wrap every load→modify→save critical section with it — create_job, update_job, remove_job, mark_job_run, advance_next_run, get_due_jobs, rewrite_skill_refs. The lock degrades to in-process-only if neither fcntl nor msvcrt is available, preserving prior behaviour. All critical sections are short (field edits, no agent execution), so contention resolves in milliseconds. Adds a regression test that proves the lock excludes a second process (an in-process threading.Lock cannot). Co-Authored-By: Claude Opus 4.8 (1M context) --- cron/jobs.py | 181 +++++++++++++++------- tests/cron/test_jobs_crossprocess_lock.py | 82 ++++++++++ 2 files changed, 206 insertions(+), 57 deletions(-) create mode 100644 tests/cron/test_jobs_crossprocess_lock.py diff --git a/cron/jobs.py b/cron/jobs.py index 52d9367ff8..aefc22cc15 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -5,6 +5,7 @@ Jobs are stored in ~/.hermes/cron/jobs.json Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md """ +import contextlib import copy import json import logging @@ -14,6 +15,19 @@ import threading import os import re import uuid + +# Cross-process advisory file locking for jobs.json critical sections. +# fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent, +# in which case _jobs_lock() degrades to in-process locking only (the old +# behaviour) rather than failing. +try: + import fcntl +except ImportError: # pragma: no cover - non-Unix + fcntl = None +try: + import msvcrt +except ImportError: # pragma: no cover - non-Windows + msvcrt = None from datetime import datetime, timedelta from pathlib import Path from hermes_constants import get_hermes_home @@ -45,6 +59,56 @@ _jobs_file_lock = threading.Lock() OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 + +def _jobs_lock_file() -> Path: + """Return the advisory lock path for the current cron directory.""" + return CRON_DIR / ".jobs.lock" + + +@contextlib.contextmanager +def _jobs_lock(): + """Serialize a load_jobs→modify→save_jobs critical section. + + Combines the in-process threading lock (cheap mutual exclusion between + the gateway's parallel tick threads) with a cross-process advisory file + lock on ``/.jobs.lock`` (mutual exclusion between the gateway process + and standalone ``hermes`` CLI invocations, which previously shared no lock + at all — a `cron pause` could be silently clobbered by a concurrent + gateway write, leaving a "paused" job still firing). + + The flock is blocking, but every critical section that uses it is short + (field updates only — no agent execution), so contention resolves in + milliseconds. If neither fcntl nor msvcrt is available the manager still + provides in-process locking, matching the historical behaviour. + """ + with _jobs_file_lock: + lock_fd = None + try: + ensure_dirs() + lock_fd = open(_jobs_lock_file(), "w", encoding="utf-8") + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + elif msvcrt is not None: + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_LOCK, 1) + except (OSError, IOError) as e: + # Never let a locking failure take down cron writes — fall back to + # in-process-only protection (still held via _jobs_file_lock). + logger.warning("jobs.json cross-process lock unavailable (%s); " + "proceeding with in-process lock only", e) + try: + yield + finally: + if lock_fd is not None: + try: + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + elif msvcrt is not None: + msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1) + except (OSError, IOError): + pass + finally: + lock_fd.close() + # Fields on a cron job that must never change after creation. ``id`` is used # as a filesystem path component under ``OUTPUT_DIR``; allowing it to be # updated lets an unsafe value (``../escape``, absolute path, nested) leak @@ -670,9 +734,10 @@ def create_job( "workdir": normalized_workdir, } - jobs = load_jobs() - jobs.append(job) - save_jobs(jobs) + with _jobs_lock(): + jobs = load_jobs() + jobs.append(job) + save_jobs(jobs) return job @@ -743,49 +808,50 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}" ) - jobs = load_jobs() - for i, job in enumerate(jobs): - if job["id"] != job_id: - continue + with _jobs_lock(): + jobs = load_jobs() + for i, job in enumerate(jobs): + if job["id"] != job_id: + continue - # Validate / normalize workdir if present in updates. Empty string or - # None both mean "clear the field" (restore old behaviour). - if "workdir" in updates: - _wd = updates["workdir"] - if _wd in {None, "", False}: - updates["workdir"] = None - else: - updates["workdir"] = _normalize_workdir(_wd) + # Validate / normalize workdir if present in updates. Empty string + # or None both mean "clear the field" (restore old behaviour). + if "workdir" in updates: + _wd = updates["workdir"] + if _wd in {None, "", False}: + updates["workdir"] = None + else: + updates["workdir"] = _normalize_workdir(_wd) - updated = _apply_skill_fields({**job, **updates}) - schedule_changed = "schedule" in updates + updated = _apply_skill_fields({**job, **updates}) + schedule_changed = "schedule" in updates - if "skills" in updates or "skill" in updates: - normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills")) - updated["skills"] = normalized_skills - updated["skill"] = normalized_skills[0] if normalized_skills else None + if "skills" in updates or "skill" in updates: + normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills")) + updated["skills"] = normalized_skills + updated["skill"] = normalized_skills[0] if normalized_skills else None - if schedule_changed: - updated_schedule = updated["schedule"] - # The API may pass schedule as a raw string (e.g. "every 10m") - # instead of a pre-parsed dict. Normalize it the same way - # create_job() does so downstream code can call .get() safely. - if isinstance(updated_schedule, str): - updated_schedule = parse_schedule(updated_schedule) - updated["schedule"] = updated_schedule - updated["schedule_display"] = updates.get( - "schedule_display", - updated_schedule.get("display", updated.get("schedule_display")), - ) - if updated.get("state") != "paused": - updated["next_run_at"] = compute_next_run(updated_schedule) + if schedule_changed: + updated_schedule = updated["schedule"] + # The API may pass schedule as a raw string (e.g. "every 10m") + # instead of a pre-parsed dict. Normalize it the same way + # create_job() does so downstream code can call .get() safely. + if isinstance(updated_schedule, str): + updated_schedule = parse_schedule(updated_schedule) + updated["schedule"] = updated_schedule + updated["schedule_display"] = updates.get( + "schedule_display", + updated_schedule.get("display", updated.get("schedule_display")), + ) + if updated.get("state") != "paused": + updated["next_run_at"] = compute_next_run(updated_schedule) - if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"): - updated["next_run_at"] = compute_next_run(updated["schedule"]) + if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"): + updated["next_run_at"] = compute_next_run(updated["schedule"]) - jobs[i] = updated - save_jobs(jobs) - return _normalize_job_record(jobs[i]) + jobs[i] = updated + save_jobs(jobs) + return _normalize_job_record(jobs[i]) return None @@ -847,19 +913,20 @@ def remove_job(job_id: str) -> bool: if not job: return False canonical_id = job["id"] - jobs = load_jobs() - original_len = len(jobs) - jobs = [j for j in jobs if j["id"] != canonical_id] - if len(jobs) < original_len: - # Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g. - # left over from before the create-time guard) fails closed without - # half-applying the removal. - job_output_dir = _job_output_dir(canonical_id) - save_jobs(jobs) - # Clean up output directory to prevent orphaned dirs accumulating - if job_output_dir.exists(): - shutil.rmtree(job_output_dir) - return True + with _jobs_lock(): + jobs = load_jobs() + original_len = len(jobs) + jobs = [j for j in jobs if j["id"] != canonical_id] + if len(jobs) < original_len: + # Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g. + # left over from before the create-time guard) fails closed without + # half-applying the removal. + job_output_dir = _job_output_dir(canonical_id) + save_jobs(jobs) + # Clean up output directory to prevent orphaned dirs accumulating + if job_output_dir.exists(): + shutil.rmtree(job_output_dir) + return True return False @@ -874,7 +941,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None, ``delivery_error`` is tracked separately from the agent error — a job can succeed (agent produced output) but fail delivery (platform down). """ - with _jobs_file_lock: + with _jobs_lock(): jobs = load_jobs() for i, job in enumerate(jobs): if job["id"] == job_id: @@ -948,7 +1015,7 @@ def advance_next_run(job_id: str) -> bool: Returns True if next_run_at was advanced, False otherwise. """ - with _jobs_file_lock: + with _jobs_lock(): jobs = load_jobs() for job in jobs: if job["id"] == job_id: @@ -973,7 +1040,7 @@ def get_due_jobs() -> List[Dict[str, Any]]: the job is fast-forwarded to the next future run instead of firing immediately. This prevents a burst of missed jobs on gateway restart. """ - with _jobs_file_lock: + with _jobs_lock(): return _get_due_jobs_locked() @@ -1158,7 +1225,7 @@ def rewrite_skill_refs( if not consolidated and not pruned_set: return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0} - with _jobs_file_lock: + with _jobs_lock(): jobs = load_jobs() rewrites: List[Dict[str, Any]] = [] changed = False diff --git a/tests/cron/test_jobs_crossprocess_lock.py b/tests/cron/test_jobs_crossprocess_lock.py new file mode 100644 index 0000000000..bae48ab0ea --- /dev/null +++ b/tests/cron/test_jobs_crossprocess_lock.py @@ -0,0 +1,82 @@ +"""Regression test for the jobs.json cross-process lock. + +Background: ``hermes cron pause`` runs in its own process (CLI → cronjob tool → +``pause_job`` → ``update_job`` → ``save_jobs``), entirely separate from the +gateway process that also writes ``jobs.json`` (``mark_job_run`` / +``advance_next_run`` / due-fast-forward). The module's ``threading.Lock`` only +serializes writers *inside one process*, so a CLI pause issued while the gateway +was live could be silently lost to a concurrent gateway write — the job kept +firing even though the CLI reported "Paused". + +``_jobs_lock()`` closes that gap with a short-held cross-process advisory file +lock. This test proves the lock actually excludes a *separate process*, which an +in-process ``threading.Lock`` cannot do. +""" + +import os +import subprocess +import sys +import textwrap +import time + +import pytest + +from cron import jobs +from hermes_constants import get_hermes_home + +# Repo root (parent of the ``cron`` package) so the child process can import it. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(jobs.__file__))) + + +@pytest.mark.skipif(jobs.fcntl is None, reason="POSIX fcntl/flock required") +def test_jobs_lock_excludes_another_process(tmp_path): + ready = tmp_path / "child_holds_lock" + release = tmp_path / "child_may_release" + holder = tmp_path / "holder.py" + holder.write_text( + textwrap.dedent( + f""" + import sys, time, pathlib + sys.path.insert(0, {_REPO_ROOT!r}) + from cron import jobs + + with jobs._jobs_lock(): + pathlib.Path({str(ready)!r}).write_text("1") + # Hold the lock until the parent signals (bounded so a wedged + # test can never hang CI). + for _ in range(1000): + if pathlib.Path({str(release)!r}).exists(): + break + time.sleep(0.01) + """ + ) + ) + + child = subprocess.Popen([sys.executable, str(holder)]) + try: + # Wait until the child is inside the critical section. + for _ in range(1000): + if ready.exists(): + break + time.sleep(0.01) + assert ready.exists(), "child never acquired _jobs_lock()" + + # While the child holds it, a non-blocking acquire of the SAME lock file + # from this process must fail. A threading.Lock could never block here. + # Resolve the lock path at runtime (not jobs._JOBS_LOCK_FILE, which is + # bound at import time) so it matches the child even when the test suite + # redirects HERMES_HOME to a per-test tempdir. + lock_file = get_hermes_home() / "cron" / ".jobs.lock" + fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT) + try: + with pytest.raises(OSError): + jobs.fcntl.flock(fd, jobs.fcntl.LOCK_EX | jobs.fcntl.LOCK_NB) + finally: + os.close(fd) + finally: + release.write_text("1") + child.wait(timeout=15) + + # Once the child has released, the lock is freely acquirable again. + with jobs._jobs_lock(): + pass From 733472952a0fbbee9ab7bdb8a0aa226f7b5e50d2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:20:59 -0700 Subject: [PATCH 34/92] fix: complete cron jobs lock salvage Route curator rollback through the same cross-process cron job lock, make save_jobs lock for legacy direct callers without deadlocking nested mutation paths, and harden the regression test so a second _jobs_lock caller really blocks across processes. --- agent/curator_backup.py | 6 +- cron/jobs.py | 81 +++++++++++++++-------- scripts/release.py | 1 + tests/cron/test_jobs_crossprocess_lock.py | 57 ++++++++++++++-- 4 files changed, 108 insertions(+), 37 deletions(-) diff --git a/agent/curator_backup.py b/agent/curator_backup.py index 7725f1c71f..944886d729 100644 --- a/agent/curator_backup.py +++ b/agent/curator_backup.py @@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]: report["attempted"] = True # we tried but there was nothing to do return report - # Load and rewrite the live jobs under the scheduler's lock. + # Load and rewrite the live jobs under the scheduler's cross-process lock. try: - from cron.jobs import load_jobs, save_jobs, _jobs_file_lock + from cron.jobs import load_jobs, save_jobs, _jobs_lock except ImportError as e: report["error"] = f"cron module unavailable: {e}" return report report["attempted"] = True try: - with _jobs_file_lock: + with _jobs_lock(): live_jobs = load_jobs() changed = False diff --git a/cron/jobs.py b/cron/jobs.py index aefc22cc15..178bd0fad8 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -55,7 +55,8 @@ JOBS_FILE = CRON_DIR / "jobs.json" # In-process lock protecting load_jobs→modify→save_jobs cycles. # Required when tick() runs jobs in parallel threads — without this, # concurrent mark_job_run / advance_next_run calls can clobber each other. -_jobs_file_lock = threading.Lock() +_jobs_file_lock = threading.RLock() +_jobs_lock_state = threading.local() OUTPUT_DIR = CRON_DIR / "output" ONESHOT_GRACE_SECONDS = 120 @@ -80,34 +81,52 @@ def _jobs_lock(): (field updates only — no agent execution), so contention resolves in milliseconds. If neither fcntl nor msvcrt is available the manager still provides in-process locking, matching the historical behaviour. + + Nested calls in the same thread reuse the held lock so legacy callers that + invoke save_jobs() inside a broader mutation section don't deadlock or try + to reacquire the advisory file lock. """ - with _jobs_file_lock: - lock_fd = None - try: - ensure_dirs() - lock_fd = open(_jobs_lock_file(), "w", encoding="utf-8") - if fcntl is not None: - fcntl.flock(lock_fd, fcntl.LOCK_EX) - elif msvcrt is not None: - msvcrt.locking(lock_fd.fileno(), msvcrt.LK_LOCK, 1) - except (OSError, IOError) as e: - # Never let a locking failure take down cron writes — fall back to - # in-process-only protection (still held via _jobs_file_lock). - logger.warning("jobs.json cross-process lock unavailable (%s); " - "proceeding with in-process lock only", e) + depth = getattr(_jobs_lock_state, "depth", 0) + if depth: + _jobs_lock_state.depth = depth + 1 try: yield finally: - if lock_fd is not None: - try: - if fcntl is not None: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - elif msvcrt is not None: - msvcrt.locking(lock_fd.fileno(), msvcrt.LK_UNLCK, 1) - except (OSError, IOError): - pass - finally: - lock_fd.close() + _jobs_lock_state.depth -= 1 + return + + with _jobs_file_lock: + _jobs_lock_state.depth = 1 + lock_fd = None + try: + try: + ensure_dirs() + lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8") + lock_fd.seek(0) + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_EX) + elif msvcrt is not None: + getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1) + except (OSError, IOError) as e: + # Never let a locking failure take down cron writes — fall back to + # in-process-only protection (still held via _jobs_file_lock). + logger.warning("jobs.json cross-process lock unavailable (%s); " + "proceeding with in-process lock only", e) + try: + yield + finally: + if lock_fd is not None: + try: + if fcntl is not None: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + elif msvcrt is not None: + getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1) + except (OSError, IOError): + pass + finally: + lock_fd.close() + finally: + _jobs_lock_state.depth = 0 # Fields on a cron job that must never change after creation. ``id`` is used # as a filesystem path component under ``OUTPUT_DIR``; allowing it to be @@ -532,8 +551,8 @@ def load_jobs() -> List[Dict[str, Any]]: ) -def save_jobs(jobs: List[Dict[str, Any]]): - """Save all jobs to storage.""" +def _save_jobs_unlocked(jobs: List[Dict[str, Any]]): + """Save all jobs to storage. Caller must hold _jobs_lock().""" ensure_dirs() fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_') try: @@ -551,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]): raise +def save_jobs(jobs: List[Dict[str, Any]]): + """Save all jobs to storage.""" + with _jobs_lock(): + _save_jobs_unlocked(jobs) + + def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: """Normalize and validate a cron job workdir. @@ -1045,7 +1070,7 @@ def get_due_jobs() -> List[Dict[str, Any]]: def _get_due_jobs_locked() -> List[Dict[str, Any]]: - """Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held.""" + """Inner implementation of get_due_jobs(); must be called with _jobs_lock held.""" now = _hermes_now() raw_jobs = load_jobs() jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)] diff --git a/scripts/release.py b/scripts/release.py index 6ab49a4095..e5b0fd2226 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -89,6 +89,7 @@ AUTHOR_MAP = { "tharushkadinujaya05@gmail.com": "0xneobyte", "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", "keiron@onehanded.com": "kmccammon", + "268233388+CiarasClaws@users.noreply.github.com": "CiarasClaws", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", diff --git a/tests/cron/test_jobs_crossprocess_lock.py b/tests/cron/test_jobs_crossprocess_lock.py index bae48ab0ea..97c0aa77cf 100644 --- a/tests/cron/test_jobs_crossprocess_lock.py +++ b/tests/cron/test_jobs_crossprocess_lock.py @@ -22,16 +22,24 @@ import time import pytest from cron import jobs -from hermes_constants import get_hermes_home + # Repo root (parent of the ``cron`` package) so the child process can import it. _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(jobs.__file__))) @pytest.mark.skipif(jobs.fcntl is None, reason="POSIX fcntl/flock required") -def test_jobs_lock_excludes_another_process(tmp_path): +def test_jobs_lock_excludes_another_process(tmp_path, monkeypatch): + cron_dir = tmp_path / "cron" + output_dir = cron_dir / "output" + monkeypatch.setattr(jobs, "CRON_DIR", cron_dir) + monkeypatch.setattr(jobs, "JOBS_FILE", cron_dir / "jobs.json") + monkeypatch.setattr(jobs, "OUTPUT_DIR", output_dir) + ready = tmp_path / "child_holds_lock" release = tmp_path / "child_may_release" + blocker_started = tmp_path / "blocker_started" + blocker_acquired = tmp_path / "blocker_acquired" holder = tmp_path / "holder.py" holder.write_text( textwrap.dedent( @@ -40,6 +48,10 @@ def test_jobs_lock_excludes_another_process(tmp_path): sys.path.insert(0, {_REPO_ROOT!r}) from cron import jobs + jobs.CRON_DIR = pathlib.Path({str(cron_dir)!r}) + jobs.JOBS_FILE = jobs.CRON_DIR / "jobs.json" + jobs.OUTPUT_DIR = jobs.CRON_DIR / "output" + with jobs._jobs_lock(): pathlib.Path({str(ready)!r}).write_text("1") # Hold the lock until the parent signals (bounded so a wedged @@ -52,7 +64,27 @@ def test_jobs_lock_excludes_another_process(tmp_path): ) ) + blocker = tmp_path / "blocker.py" + blocker.write_text( + textwrap.dedent( + f""" + import sys, pathlib + sys.path.insert(0, {_REPO_ROOT!r}) + from cron import jobs + + jobs.CRON_DIR = pathlib.Path({str(cron_dir)!r}) + jobs.JOBS_FILE = jobs.CRON_DIR / "jobs.json" + jobs.OUTPUT_DIR = jobs.CRON_DIR / "output" + + pathlib.Path({str(blocker_started)!r}).write_text("1") + with jobs._jobs_lock(): + pathlib.Path({str(blocker_acquired)!r}).write_text("1") + """ + ) + ) + child = subprocess.Popen([sys.executable, str(holder)]) + blocker_child = None try: # Wait until the child is inside the critical section. for _ in range(1000): @@ -63,19 +95,32 @@ def test_jobs_lock_excludes_another_process(tmp_path): # While the child holds it, a non-blocking acquire of the SAME lock file # from this process must fail. A threading.Lock could never block here. - # Resolve the lock path at runtime (not jobs._JOBS_LOCK_FILE, which is - # bound at import time) so it matches the child even when the test suite - # redirects HERMES_HOME to a per-test tempdir. - lock_file = get_hermes_home() / "cron" / ".jobs.lock" + lock_file = jobs._jobs_lock_file() fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT) try: with pytest.raises(OSError): jobs.fcntl.flock(fd, jobs.fcntl.LOCK_EX | jobs.fcntl.LOCK_NB) finally: os.close(fd) + + # A second _jobs_lock() caller in another process should block until the + # holder releases, rather than falling through with only a process-local + # threading lock. + blocker_child = subprocess.Popen([sys.executable, str(blocker)]) + for _ in range(1000): + if blocker_started.exists(): + break + time.sleep(0.01) + assert blocker_started.exists(), "blocker process never started" + time.sleep(0.05) + assert not blocker_acquired.exists(), "second process entered _jobs_lock() while held" finally: release.write_text("1") child.wait(timeout=15) + if blocker_child is not None: + blocker_child.wait(timeout=15) + + assert blocker_acquired.exists(), "second process did not acquire _jobs_lock() after release" # Once the child has released, the lock is freely acquirable again. with jobs._jobs_lock(): From be7c919bf9773bd2d6c0f2b090575fd8159a4a02 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:08:24 -0700 Subject: [PATCH 35/92] fix(process): label background completion causes (#46659) Track why a background process finished and include that source in notify-on-complete messages so SIGTERM from process.kill, kill_all, backend loss, and ordinary exits are distinguishable. --- gateway/run.py | 19 +++++--- tests/test_tui_gateway_server.py | 2 +- tests/tools/test_notify_on_complete.py | 33 +++++++++++++- tests/tools/test_process_registry.py | 33 +++++++++++++- tools/process_registry.py | 60 ++++++++++++++++++++++---- 5 files changed, 128 insertions(+), 19 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index f469647be4..475320c65a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12318,7 +12318,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if session.exited: # --- Agent-triggered completion: inject synthetic message --- # Skip if the agent already consumed the result via wait/poll/log - from tools.process_registry import process_registry as _pr_check + from tools.process_registry import format_process_notification, process_registry as _pr_check if agent_notify and not _pr_check.is_completion_consumed(session_id): from tools.ansi_strip import strip_ansi _raw = strip_ansi(session.output_buffer) if session.output_buffer else "" @@ -12334,12 +12334,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}" else: _out = _raw - synth_text = ( - f"[IMPORTANT: Background process {session_id} completed " - f"(exit code {session.exit_code}).\n" - f"Command: {session.command}\n" - f"Output:\n{_out}]" - ) + synth_text = format_process_notification({ + "type": "completion", + "session_id": session_id, + "command": session.command, + "exit_code": session.exit_code, + "completion_reason": getattr(session, "completion_reason", "exited"), + "termination_source": getattr(session, "termination_source", ""), + "output": _out, + }) + if not synth_text: + break source = self._build_process_event_source({ "session_id": session_id, "session_key": session_key, diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4379d80aeb..90a7f20025 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6652,7 +6652,7 @@ def test_notification_poller_delivers_completion(monkeypatch): # Should have triggered an agent turn assert len(turns) == 1 - assert "[IMPORTANT: Background process proc_poller_test completed" in turns[0] + assert "[IMPORTANT: Background process proc_poller_test completed normally" in turns[0] finally: server._sessions.pop("sid_poll", None) while not process_registry.completion_queue.empty(): diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py index 84bf5f1f6b..5c2af09441 100644 --- a/tests/tools/test_notify_on_complete.py +++ b/tests/tools/test_notify_on_complete.py @@ -12,7 +12,7 @@ import json import os import time import pytest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from tools.process_registry import ( ProcessRegistry, @@ -99,6 +99,8 @@ class TestCompletionQueue: assert completion["session_id"] == s.id assert completion["command"] == "echo hello" assert completion["exit_code"] == 0 + assert completion["completion_reason"] == "exited" + assert completion["termination_source"] == "" assert "build succeeded" in completion["output"] def test_move_to_finished_nonzero_exit(self, registry): @@ -138,6 +140,35 @@ class TestCompletionQueue: completion = registry.completion_queue.get_nowait() assert completion["exit_code"] == -15 # from the first (kill) call + def test_kill_process_sets_completion_reason_and_source(self, registry): + s = _make_session(notify_on_complete=True, output="stopping") + s.process = MagicMock() + s.process.pid = 4242 + registry._running[s.id] = s + + class FakeProcess: + def __init__(self, pid): + self.pid = pid + + def children(self, recursive=False): + return [] + + def terminate(self): + pass + + import psutil as _psutil + + with patch.object(_psutil, "Process", side_effect=lambda pid: FakeProcess(pid)), \ + patch.object(registry, "_write_checkpoint"): + result = registry.kill_process(s.id) + + assert result["status"] == "killed" + assert result["completion_reason"] == "killed" + assert result["termination_source"] == "process.kill" + completion = registry.completion_queue.get_nowait() + assert completion["completion_reason"] == "killed" + assert completion["termination_source"] == "process.kill" + def test_output_truncated_to_2000(self, registry): """Long output is truncated to last 2000 chars.""" long_output = "x" * 5000 diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index da48183d46..967849a194 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -1012,12 +1012,41 @@ def test_format_completion_event(): "output": "done", } result = format_process_notification(evt) - assert "[IMPORTANT: Background process proc_abc completed" in result + assert "[IMPORTANT: Background process proc_abc completed normally" in result assert "exit code 0" in result assert "Command: sleep 5" in result assert "Output:\ndone]" in result +def test_format_killed_completion_event_names_source_and_signal(): + evt = { + "type": "completion", + "session_id": "proc_killed", + "command": "sleep 5", + "exit_code": -15, + "completion_reason": "killed", + "termination_source": "process.kill", + "output": "", + } + result = format_process_notification(evt) + assert "proc_killed terminated by process.kill" in result + assert "exit code -15, SIGTERM" in result + + +def test_format_external_sigterm_does_not_claim_agent_kill(): + evt = { + "type": "completion", + "session_id": "proc_external", + "command": "sleep 5", + "exit_code": 143, + "output": "", + } + result = format_process_notification(evt) + assert "proc_external exited" in result + assert "terminated by" not in result + assert "exit code 143, SIGTERM" in result + + def test_format_watch_match_event(): evt = { "type": "watch_match", @@ -1087,7 +1116,7 @@ def test_drain_notifications_returns_pending_events(): results = process_registry.drain_notifications() assert len(results) == 2 assert results[0][0]["session_id"] == "proc_drain1" - assert "proc_drain1 completed" in results[0][1] + assert "proc_drain1 completed normally" in results[0][1] assert results[1][0]["session_id"] == "proc_drain2" assert "watch pattern" in results[1][1] finally: diff --git a/tools/process_registry.py b/tools/process_registry.py index 9326696998..6c3d61ce5f 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -100,6 +100,8 @@ class ProcessSession: started_at: float = 0.0 # time.time() of spawn exited: bool = False # Whether the process has finished exit_code: Optional[int] = None # Exit code (None if still running) + completion_reason: str = "exited" # exited|killed|lost|failed_start|already_exited + termination_source: str = "" # process.kill|kill_all|backend_lost|failed_start output_buffer: str = "" # Rolling output (last MAX_OUTPUT_CHARS) max_output_chars: int = MAX_OUTPUT_CHARS detached: bool = False # True if recovered from crash (no pipe) @@ -720,10 +722,14 @@ class ProcessRegistry: session.exit_code = int(result.get("returncode", -1)) if session.exit_code == 0: session.exit_code = -1 + session.completion_reason = "failed_start" + session.termination_source = "failed_start" session.output_buffer = result.get("output", "").strip() except Exception as e: session.exited = True session.exit_code = -1 + session.completion_reason = "failed_start" + session.termination_source = "failed_start" session.output_buffer = f"Failed to start: {e}" if not session.exited: @@ -774,7 +780,9 @@ class ProcessRegistry: except Exception as e: logger.debug("Process wait timed out or failed: %s", e) session.exited = True - session.exit_code = session.process.returncode + if session.completion_reason != "killed": + session.exit_code = session.process.returncode + session.completion_reason = "exited" self._move_to_finished(session) def _env_poller_loop( @@ -820,6 +828,8 @@ class ProcessRegistry: except (ValueError, IndexError): session.exit_code = -1 session.exited = True + if session.completion_reason != "killed": + session.completion_reason = "exited" self._move_to_finished(session) return @@ -827,6 +837,8 @@ class ProcessRegistry: # Environment might be gone (sandbox reaped, etc.) session.exited = True session.exit_code = -1 + session.completion_reason = "lost" + session.termination_source = "backend_lost" self._move_to_finished(session) return @@ -858,7 +870,9 @@ class ProcessRegistry: except Exception as e: logger.debug("PTY wait timed out or failed: %s", e) session.exited = True - session.exit_code = pty.exitstatus if hasattr(pty, 'exitstatus') else -1 + if session.completion_reason != "killed": + session.exit_code = pty.exitstatus if hasattr(pty, 'exitstatus') else -1 + session.completion_reason = "exited" self._move_to_finished(session) def _move_to_finished(self, session: ProcessSession): @@ -886,6 +900,8 @@ class ProcessRegistry: "session_key": session.session_key, "command": session.command, "exit_code": session.exit_code, + "completion_reason": session.completion_reason, + "termination_source": session.termination_source, "output": output_tail, }) @@ -985,7 +1001,9 @@ class ProcessRegistry: if len(session.output_buffer) > session.max_output_chars: session.output_buffer = session.output_buffer[-session.max_output_chars:] session.exited = True - session.exit_code = rc + if session.completion_reason != "killed": + session.exit_code = rc + session.completion_reason = "exited" logger.info( "Reconciled session %s: direct child exited with code %s but reader " "was still blocked (orphaned pipe). Flipped to exited.", @@ -1018,6 +1036,8 @@ class ProcessRegistry: } if session.exited: result["exit_code"] = session.exit_code + result["completion_reason"] = session.completion_reason + result["termination_source"] = session.termination_source self._completion_consumed.add(session_id) if session.detached: result["detached"] = True @@ -1106,6 +1126,8 @@ class ProcessRegistry: result = { "status": "exited", "exit_code": session.exit_code, + "completion_reason": session.completion_reason, + "termination_source": session.termination_source, "output": strip_ansi(session.output_buffer[-2000:]), } if timeout_note: @@ -1137,7 +1159,7 @@ class ProcessRegistry: result["timeout_note"] = f"Waited {effective_timeout}s, process still running" return result - def kill_process(self, session_id: str) -> dict: + def kill_process(self, session_id: str, *, source: str = "process.kill") -> dict: """Kill a background process.""" session = self.get(session_id) if session is None: @@ -1201,9 +1223,16 @@ class ProcessRegistry: } session.exited = True session.exit_code = -15 # SIGTERM + session.completion_reason = "killed" + session.termination_source = source self._move_to_finished(session) self._write_checkpoint() - return {"status": "killed", "session_id": session.id} + return { + "status": "killed", + "session_id": session.id, + "completion_reason": session.completion_reason, + "termination_source": session.termination_source, + } except Exception as e: return {"status": "error", "error": str(e)} @@ -1347,7 +1376,7 @@ class ProcessRegistry: killed = 0 for session in targets: - result = self.kill_process(session.id) + result = self.kill_process(session.id, source="kill_all") if result.get("status") in {"killed", "already_exited"}: killed += 1 return killed @@ -1532,9 +1561,24 @@ def format_process_notification(evt: dict) -> "str | None": _exit = evt.get("exit_code", "?") _out = evt.get("output", "") + _reason = evt.get("completion_reason") or "exited" + _source = evt.get("termination_source") or "" + _signal = "" + if _exit in {-15, 143, "-15", "143"}: + _signal = ", SIGTERM" + if _reason == "killed": + _status = f"terminated by {_source or 'Hermes'}" + elif _reason == "lost": + _status = "marked lost because the process backend disappeared" + elif _reason == "failed_start": + _status = "failed to start" + elif _exit == 0: + _status = "completed normally" + else: + _status = "exited" return ( - f"[IMPORTANT: Background process {_sid} completed " - f"(exit code {_exit}).\n" + f"[IMPORTANT: Background process {_sid} {_status} " + f"(exit code {_exit}{_signal}).\n" f"Command: {_cmd}\n" f"Output:\n{_out}]" ) From ba3883cd186848edc5756689b63197a94c517ca9 Mon Sep 17 00:00:00 2001 From: goku94123 Date: Mon, 15 Jun 2026 21:11:02 +1000 Subject: [PATCH 36/92] fix(minimax): enable reasoning extra_body for api.minimax.io --- run_agent.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/run_agent.py b/run_agent.py index 2bf27d5751..8568c3eb2a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4792,6 +4792,15 @@ class AIAgent: return bool(github_model_reasoning_efforts(self.model)) except Exception: return False + if base_url_host_matches(self._base_url_lower, "api.minimax.io"): + # MiniMax (api.minimax.io): enable reasoning extra_body + # (reasoning_split, thinking, reasoning_effort) for both the + # Anthropic-format and OpenAI-format endpoints. Without this the + # safety gate strips those fields before they reach the API, so M3 + # leaks thinking into response content and burns output tokens. M3 + # specifically benefits from the OpenAI-compatible endpoint + # (/v1/chat/completions) for prompt caching. + return True if (self.provider or "").strip().lower() == "lmstudio": opts = self._lmstudio_reasoning_options_cached() # "off-only" (or absent) means no real reasoning capability. From 49e743985aaf54c1f8317fbd253a82a9ca41c8e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 05:37:28 -0700 Subject: [PATCH 37/92] fix: route minimax m3 reasoning controls through profile Follow up PR #46609's api.minimax.io reasoning report by moving the behavior out of the broad run_agent host gate and into the MiniMax provider profile. Only MiniMax-M3 on the documented OpenAI-compatible /v1 route gets reasoning_split/thinking/reasoning_effort; Anthropic-format MiniMax and non-M3 models keep their existing wire shapes. Co-authored-by: goku94123 --- agent/transports/chat_completions.py | 1 + plugins/model-providers/minimax/__init__.py | 62 +++++++++- run_agent.py | 9 -- scripts/release.py | 1 + .../model_providers/test_minimax_profile.py | 112 ++++++++++++++++++ 5 files changed, 171 insertions(+), 14 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 49bc91f44d..c0b2a13d25 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -531,6 +531,7 @@ class ChatCompletionsTransport(ProviderTransport): supports_reasoning=params.get("supports_reasoning", False), qwen_session_metadata=params.get("qwen_session_metadata"), model=model, + base_url=params.get("base_url"), ollama_num_ctx=params.get("ollama_num_ctx"), session_id=params.get("session_id"), ) diff --git a/plugins/model-providers/minimax/__init__.py b/plugins/model-providers/minimax/__init__.py index 6d77536ace..7dbaf4000c 100644 --- a/plugins/model-providers/minimax/__init__.py +++ b/plugins/model-providers/minimax/__init__.py @@ -1,13 +1,65 @@ """MiniMax provider profiles (international + China). -Both use anthropic_messages api_mode — their inference_base_url -ends with /anthropic which triggers auto-detection to anthropic_messages. +The default API-key routes use anthropic_messages because their base URLs end +with /anthropic. Users can opt MiniMax-M3 into the OpenAI-compatible endpoint +with base_url=https://api.minimax.io/v1; that route needs MiniMax-specific +reasoning controls in extra_body. """ +from typing import Any +from urllib.parse import urlparse + from providers import register_provider from providers.base import ProviderProfile -minimax = ProviderProfile( + +def _is_minimax_global_openai_base_url(base_url: str | None) -> bool: + parsed = urlparse(str(base_url or "").strip()) + if (parsed.hostname or "").lower() != "api.minimax.io": + return False + path = parsed.path.rstrip("/").lower() + return path == "/v1" + + +def _is_minimax_m3(model: str | None) -> bool: + normalized = str(model or "").strip().lower() + return normalized in {"minimax-m3", "minimax/minimax-m3"} + + +class MiniMaxProfile(ProviderProfile): + """MiniMax — M3 OpenAI-compatible reasoning controls.""" + + def build_api_kwargs_extras( + self, + *, + reasoning_config: dict | None = None, + model: str | None = None, + base_url: str | None = None, + **context: Any, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Emit M3 reasoning controls for api.minimax.io/v1. + + MiniMax-M3's OpenAI-compatible endpoint keeps thinking inline unless + ``reasoning_split`` is sent, so always request the split format on that + route. ``thinking`` controls the M3 mode; Hermes' effort levels are not + a MiniMax depth knob here, so they only select adaptive vs disabled. + """ + if not _is_minimax_global_openai_base_url(base_url) or not _is_minimax_m3(model): + return {}, {} + + extra_body: dict[str, Any] = {"reasoning_split": True} + + if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False: + extra_body["thinking"] = {"type": "disabled"} + return extra_body, {} + + if reasoning_config is not None: + extra_body["thinking"] = {"type": "adaptive"} + + return extra_body, {} + + +minimax = MiniMaxProfile( name="minimax", aliases=("mini-max",), api_mode="anthropic_messages", @@ -17,7 +69,7 @@ minimax = ProviderProfile( default_aux_model="MiniMax-M3", ) -minimax_cn = ProviderProfile( +minimax_cn = MiniMaxProfile( name="minimax-cn", aliases=("minimax-china", "minimax_cn"), api_mode="anthropic_messages", @@ -27,7 +79,7 @@ minimax_cn = ProviderProfile( default_aux_model="MiniMax-M3", ) -minimax_oauth = ProviderProfile( +minimax_oauth = MiniMaxProfile( name="minimax-oauth", aliases=("minimax_oauth", "minimax-oauth-io"), api_mode="anthropic_messages", diff --git a/run_agent.py b/run_agent.py index 8568c3eb2a..2bf27d5751 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4792,15 +4792,6 @@ class AIAgent: return bool(github_model_reasoning_efforts(self.model)) except Exception: return False - if base_url_host_matches(self._base_url_lower, "api.minimax.io"): - # MiniMax (api.minimax.io): enable reasoning extra_body - # (reasoning_split, thinking, reasoning_effort) for both the - # Anthropic-format and OpenAI-format endpoints. Without this the - # safety gate strips those fields before they reach the API, so M3 - # leaks thinking into response content and burns output tokens. M3 - # specifically benefits from the OpenAI-compatible endpoint - # (/v1/chat/completions) for prompt caching. - return True if (self.provider or "").strip().lower() == "lmstudio": opts = self._lmstudio_reasoning_options_cached() # "off-only" (or absent) means no real reasoning capability. diff --git a/scripts/release.py b/scripts/release.py index e5b0fd2226..4ca3884122 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -1318,6 +1318,7 @@ AUTHOR_MAP = { "agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity) "jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback) "caojiguang@gmail.com": "caojiguang", # PR #35117 carries #31853 (weixin _api_post/_api_get wait_for) + "gooku94123@gmail.com": "goku94123", # PR #46609 salvage (MiniMax reasoning extra_body) # pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan "ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix) # Kanban bug-fix batch salvage (May 2026) diff --git a/tests/plugins/model_providers/test_minimax_profile.py b/tests/plugins/model_providers/test_minimax_profile.py index e66b0dea8b..58c7b4fbe3 100644 --- a/tests/plugins/model_providers/test_minimax_profile.py +++ b/tests/plugins/model_providers/test_minimax_profile.py @@ -118,3 +118,115 @@ class TestMinimaxAuxModelNotHighspeed: "is a -highspeed variant — that costs 2x for the same model and " "broke #4082 the first time. Revert to plain M2.7 or M3." ) + + +class TestMinimaxM3OpenAIReasoningWireShape: + """MiniMax-M3 on api.minimax.io/v1 gets MiniMax's OpenAI-compatible knobs.""" + + def test_m3_openai_route_requests_reasoning_split_by_default(self): + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("minimax") + assert profile is not None + extra_body, top_level = profile.build_api_kwargs_extras( + reasoning_config=None, + model="MiniMax-M3", + base_url="https://api.minimax.io/v1", + ) + assert extra_body == {"reasoning_split": True} + assert top_level == {} + + def test_m3_openai_route_maps_explicit_effort_to_adaptive_only(self): + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("minimax") + assert profile is not None + extra_body, top_level = profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model="MiniMax-M3", + base_url="https://api.minimax.io/v1", + ) + assert extra_body == { + "reasoning_split": True, + "thinking": {"type": "adaptive"}, + } + assert top_level == {} + + def test_m3_openai_route_does_not_send_reasoning_effort(self): + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("minimax") + assert profile is not None + extra_body, _top_level = profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "xhigh"}, + model="MiniMax-M3", + base_url="https://api.minimax.io/v1/", + ) + assert extra_body == { + "reasoning_split": True, + "thinking": {"type": "adaptive"}, + } + + def test_m3_openai_route_can_disable_thinking(self): + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("minimax") + assert profile is not None + extra_body, top_level = profile.build_api_kwargs_extras( + reasoning_config={"enabled": False, "effort": "high"}, + model="MiniMax-M3", + base_url="https://api.minimax.io/v1", + ) + assert extra_body == { + "reasoning_split": True, + "thinking": {"type": "disabled"}, + } + assert top_level == {} + + @pytest.mark.parametrize( + "model,base_url", + [ + ("MiniMax-M2.7", "https://api.minimax.io/v1"), + ("MiniMax-M3", "https://api.minimax.io/anthropic"), + ("MiniMax-M3", "https://api.minimaxi.com/v1"), + ], + ) + def test_non_m3_or_non_global_openai_routes_emit_no_openai_reasoning_knobs( + self, model, base_url + ): + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("minimax") + assert profile is not None + extra_body, top_level = profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model=model, + base_url=base_url, + ) + assert extra_body == {} + assert top_level == {} + + def test_transport_threads_base_url_to_profile(self): + import model_tools # noqa: F401 + import providers + from agent.transports.chat_completions import ChatCompletionsTransport + + profile = providers.get_provider_profile("minimax") + assert profile is not None + kwargs = ChatCompletionsTransport().build_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "ping"}], + tools=None, + provider_profile=profile, + reasoning_config={"enabled": True, "effort": "medium"}, + base_url="https://api.minimax.io/v1", + ) + assert kwargs["extra_body"] == { + "reasoning_split": True, + "thinking": {"type": "adaptive"}, + } From fbabf438a17cc16a768e567eea832e39a9e0a40b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 20:31:36 +0700 Subject: [PATCH 38/92] fix(desktop): sync $connection on profile switch so remote profiles attach images as bytes The renderer's $connection seeds from the PRIMARY (window) backend at boot and otherwise only refreshes on a sleep/wake reconnect. Activating a background profile (ensureGatewayProfile) pointed the live gateway + REST at that profile's backend but never updated $connection, so its `mode` stayed stuck on the primary. With a local primary and a remote pool profile active, every code path that branches on local-vs-remote misfired: image attachments went out via the path-based `image.attach` instead of `image.attach_bytes`, handing the remote gateway a client-only Windows path it can't resolve ("image not found: C:\..."), and the /api/fs/* file browser and /api/media fetches targeted the wrong machine. Resync $connection from the now-active profile's descriptor right after the gateway swap, so the remote-aware paths follow the live backend. Best-effort: a failed descriptor fetch leaves the prior connection intact for boot/reconnect to resync. Single-profile users are unaffected (the same-profile fast path never runs the swap). Fixes #46651 --- apps/desktop/src/store/profile.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 67b708fb21..4c8ffc3540 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -12,6 +12,7 @@ import { storedStringRecord } from '@/lib/storage' import { $gateway, ensureGatewayForProfile } from '@/store/gateway' +import { setConnection } from '@/store/session' import type { ProfileInfo } from '@/types/hermes' // Canonical key for a profile: trimmed, empty → "default". Used everywhere we @@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom(null) let gatewaySwitch: Promise | null = null +// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with +// the profile the live gateway is now on. $connection seeds from the PRIMARY +// (window) backend at boot and otherwise only refreshes on a sleep/wake +// reconnect — so activating a *background* profile left $connection describing +// the primary, with the wrong `mode` for everything that branches on +// local-vs-remote. Headline symptom: with a local primary and a remote pool +// profile active, image attachments went out via the path-based `image.attach` +// instead of `image.attach_bytes`, handing the remote gateway a client-only +// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file +// browser and /api/media fetches targeted the wrong machine (#46651). +// Best-effort: a failed descriptor fetch leaves the prior connection intact for +// boot/reconnect to resync. +async function syncConnectionToActiveProfile(profile: string): Promise { + const getConnection = window.hermesDesktop?.getConnection + + if (!getConnection) { + return + } + + try { + setConnection(await getConnection(profile)) + } catch { + // Leave the prior connection in place; boot/reconnect resyncs it later. + } +} + // Make `profile`'s backend the active gateway, lazily opening its socket if it // isn't live yet. Unlike the old single-socket swap, background profiles keep // their sockets — so their sessions keep streaming concurrently. A null/empty @@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined): // the active gateway at it — without closing the profile you came from. await ensureGatewayForProfile(target) $activeGatewayProfile.set(target) + // The active backend just changed; resync $connection so remote-aware + // paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow. + await syncConnectionToActiveProfile(target) })() try { From bee13817f06995cf690ee4e4aafed956be78ab69 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 20:31:42 +0700 Subject: [PATCH 39/92] test(desktop): cover $connection resync on profile switch Asserts ensureGatewayProfile keeps $connection in lockstep with the active profile's backend: activating a remote pool profile flips mode to remote, returning to default resyncs to local, a failed descriptor fetch leaves the prior connection intact, and a same-profile activation doesn't churn it. Regression coverage for #46651. --- apps/desktop/src/store/profile.test.ts | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apps/desktop/src/store/profile.test.ts diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts new file mode 100644 index 0000000000..d98ee70308 --- /dev/null +++ b/apps/desktop/src/store/profile.test.ts @@ -0,0 +1,89 @@ +import { atom } from 'nanostores' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesConnection } from '@/global' + +// Keep profile.ts's side-effecting imports inert: the gateway socket layer and +// the REST query client must not run for real in a unit test. +const ensureGatewayForProfile = vi.fn(async () => undefined) +const $gateway = atom({ id: 'live-socket' }) + +vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile })) +vi.mock('@/hermes', () => ({ + getProfiles: vi.fn(async () => ({ profiles: [] })), + setApiRequestProfile: vi.fn() +})) +vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } })) + +const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile') +const { $connection } = await import('./session') + +const remoteConn = (over: Partial = {}): HermesConnection => + ({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection + +const localConn = (over: Partial = {}): HermesConnection => + ({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection + +const getConnection = vi.fn<(profile?: string | null) => Promise>() + +beforeEach(() => { + getConnection.mockReset() + ensureGatewayForProfile.mockClear() + $gateway.set({ id: 'live-socket' }) + $activeGatewayProfile.set('default') + $connection.set(localConn()) + vi.stubGlobal('window', { hermesDesktop: { getConnection } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + $connection.set(null) +}) + +describe('ensureGatewayProfile → $connection sync (#46651)', () => { + it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => { + // Regression: the primary window backend is local, so $connection.mode is + // "local". Activating the remote profile must flip it to "remote" — without + // this, image attach uses path-based image.attach against the remote + // gateway ("image not found: C:\\…") instead of image.attach_bytes. + getConnection.mockResolvedValue(remoteConn()) + + await ensureGatewayProfile('vps-remote') + + expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote') + expect(getConnection).toHaveBeenCalledWith('vps-remote') + expect($connection.get()?.mode).toBe('remote') + expect($connection.get()?.profile).toBe('vps-remote') + }) + + it('resyncs $connection back to local when returning to the default profile', async () => { + $activeGatewayProfile.set('vps-remote') + $connection.set(remoteConn()) + getConnection.mockResolvedValue(localConn()) + + await ensureGatewayProfile('default') + + expect(getConnection).toHaveBeenCalledWith('default') + expect($connection.get()?.mode).toBe('local') + }) + + it('leaves the prior connection intact when the descriptor fetch fails', async () => { + getConnection.mockRejectedValue(new Error('backend unreachable')) + + await ensureGatewayProfile('vps-remote') + + // Best-effort: boot/reconnect resyncs later; we must not null it out here. + expect($connection.get()?.mode).toBe('local') + }) + + it('does not churn $connection when the target is already the active profile', async () => { + $activeGatewayProfile.set('vps-remote') + $connection.set(remoteConn()) + + await ensureGatewayProfile('vps-remote') + + expect(getConnection).not.toHaveBeenCalled() + expect(ensureGatewayForProfile).not.toHaveBeenCalled() + expect($connection.get()?.mode).toBe('remote') + }) +}) From 2f2e3616b4064e79846833eadafc6e5b5140f799 Mon Sep 17 00:00:00 2001 From: Amy Ravenwolf Date: Sat, 25 Apr 2026 19:20:45 +0200 Subject: [PATCH 40/92] fix(config): read browser inactivity timeout from config --- tests/tools/test_browser_hardening.py | 23 +++++++++++++++++++++++ tools/browser_tool.py | 21 +++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py index 657edad2a2..23ff9c93b9 100644 --- a/tests/tools/test_browser_hardening.py +++ b/tests/tools/test_browser_hardening.py @@ -115,6 +115,29 @@ class TestCommandTimeoutCache: mock_read.assert_called_once() +class TestSessionInactivityTimeout: + + def test_default_is_300(self, monkeypatch): + from tools.browser_tool import _get_session_inactivity_timeout + monkeypatch.delenv("BROWSER_INACTIVITY_TIMEOUT", raising=False) + with patch("hermes_cli.config.read_raw_config", return_value={}): + assert _get_session_inactivity_timeout() == 300 + + def test_reads_from_config_over_env(self, monkeypatch): + from tools.browser_tool import _get_session_inactivity_timeout + monkeypatch.setenv("BROWSER_INACTIVITY_TIMEOUT", "120") + cfg = {"browser": {"inactivity_timeout": 900}} + with patch("hermes_cli.config.read_raw_config", return_value=cfg): + assert _get_session_inactivity_timeout() == 900 + + def test_floor_at_30_seconds(self, monkeypatch): + from tools.browser_tool import _get_session_inactivity_timeout + monkeypatch.setenv("BROWSER_INACTIVITY_TIMEOUT", "120") + cfg = {"browser": {"inactivity_timeout": 1}} + with patch("hermes_cli.config.read_raw_config", return_value=cfg): + assert _get_session_inactivity_timeout() == 30 + + # --------------------------------------------------------------------------- # Caching: _discover_homebrew_node_dirs # --------------------------------------------------------------------------- diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 66684b8ee3..2c56bf9bb7 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1177,10 +1177,23 @@ _cleanup_done = False # Inactivity Timeout Configuration # ============================================================================= -# Session inactivity timeout (seconds) - cleanup if no activity for this long -# Default: 5 minutes. Needs headroom for LLM reasoning between browser commands, -# especially when subagents are doing multi-step browser tasks. -BROWSER_SESSION_INACTIVITY_TIMEOUT = env_int("BROWSER_INACTIVITY_TIMEOUT", 300) +# Session inactivity timeout (seconds) - cleanup if no activity for this long. +# config.yaml is authoritative; BROWSER_INACTIVITY_TIMEOUT remains a legacy +# fallback so old deployments keep working if they have not migrated yet. +def _get_session_inactivity_timeout() -> int: + result = env_int("BROWSER_INACTIVITY_TIMEOUT", 300) + try: + from hermes_cli.config import read_raw_config + cfg = read_raw_config() + val = cfg_get(cfg, "browser", "inactivity_timeout") + if val is not None: + result = max(int(val), 30) # Floor at 30s to avoid instant reaping + except Exception as e: + logger.debug("Could not read inactivity_timeout from config: %s", e) + return result + + +BROWSER_SESSION_INACTIVITY_TIMEOUT = _get_session_inactivity_timeout() # Track last activity time per session _session_last_activity: Dict[str, float] = {} From 5b2604df999c4c16149600fd022934221b25e25b Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Mon, 18 May 2026 03:27:03 +0200 Subject: [PATCH 41/92] fix(state): skip redundant trigram backfill before v11 FTS rebuild --- hermes_state.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hermes_state.py b/hermes_state.py index 7ca3db06a7..83bc7bf401 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1123,11 +1123,15 @@ class SessionDB: # backfills, index changes tied to a specific version step) stay # in a version-gated chain. Column additions are handled by # _reconcile_columns() above and no longer need entries here. - if current_version < 10: + if current_version < 10 and SCHEMA_VERSION < 11: # v10: trigram FTS5 table for CJK/substring search. The # virtual table + triggers are created unconditionally via # FTS_TRIGRAM_SQL below, but existing rows need a one-time # backfill into the FTS index. + # + # When upgrading straight to v11+, skip this backfill: v11 + # drops and rebuilds both FTS tables anyway, so doing the v10 + # trigram backfill first only burns startup time and WAL space. if fts5_available: _fts_trigram_exists = self._fts_table_probe( cursor, "messages_fts_trigram" From 5035fa9029a4391f694e3e3407d2e85cd4f36f23 Mon Sep 17 00:00:00 2001 From: Amy Ravenwolf Date: Fri, 3 Apr 2026 16:19:13 +0200 Subject: [PATCH 42/92] feat(display): show delegate_task goals in tool progress notifications Previously, delegate_task in batch mode only showed '3 parallel tasks' without revealing what the tasks actually are. Single-task mode showed the goal via the primary_args fallback, but batch mode had no goal extraction. Changes: - build_tool_preview(): Add dedicated delegate_task handler that extracts individual task goals from both single and batch modes. Batch shows '3 tasks: Goal A | Goal B | Goal C'. - _get_cute_tool_message_impl(): Show individual goals in CLI cute messages for batch delegate calls ('3x: Goal A | Goal B'). - Add 4 tests covering single goal, batch goals, missing goals, and no-goal edge case. --- agent/display.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/agent/display.py b/agent/display.py index 8514279888..55326b5b01 100644 --- a/agent/display.py +++ b/agent/display.py @@ -191,6 +191,15 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - "clarify": "question", "skill_manage": "name", } + # delegate_task: show goal (single) or individual task goals (batch) + if tool_name == "delegate_task": + tasks = args.get("tasks") + if tasks and isinstance(tasks, list): + goals = [_oneline(t.get("goal", "?"))[:40] for t in tasks if isinstance(t, dict)] + return f"{len(tasks)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" + goal = args.get("goal", "") + return _oneline(goal) if goal else None + if tool_name == "process": action = args.get("action", "") sid = args.get("session_id", "") @@ -1019,7 +1028,9 @@ def get_cute_tool_message( if tool_name == "delegate_task": tasks = args.get("tasks") if tasks and isinstance(tasks, list): - return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}") + goals = [_oneline(t.get("goal", "?"))[:30] for t in tasks if isinstance(t, dict)] + detail = " | ".join(goals) if goals else "parallel" + return _wrap(f"┊ 🔀 delegate {len(tasks)}x: {_trunc(detail, 35)} {dur}") return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}") preview = build_tool_preview(tool_name, args) or "" From ead38107a2f2b6d6a71e92e978fe98a684bd3be8 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Sun, 31 May 2026 17:14:47 +0200 Subject: [PATCH 43/92] feat(status): restore model and context in gateway status PROBLEM: The old public /status PR drifted out of the current Amy patch stack, leaving /status without the model/provider, context window, or explicit cumulative token label that Wolfram uses to monitor context pressure from chat. SOLUTION: Re-port the feature onto the current gateway status handler. Prefer live/cached agent runtime metadata, fall back to SessionDB + SessionStore state between turns, add localized status model/context lines, and keep token totals explicitly labeled cumulative. Verification: tests/gateway/test_status_command.py, tests/hermes_cli/test_commands.py --- gateway/slash_commands.py | 128 +++++++++++++++++++++++++-- hermes_cli/commands.py | 2 +- locales/af.yaml | 4 + locales/de.yaml | 4 + locales/en.yaml | 4 + locales/es.yaml | 4 + locales/fr.yaml | 4 + locales/ga.yaml | 4 + locales/hu.yaml | 4 + locales/it.yaml | 4 + locales/ja.yaml | 4 + locales/ko.yaml | 4 + locales/pt.yaml | 4 + locales/ru.yaml | 4 + locales/tr.yaml | 4 + locales/uk.yaml | 4 + locales/zh-hant.yaml | 4 + locales/zh.yaml | 4 + tests/gateway/test_status_command.py | 73 +++++++++++++++ 19 files changed, 257 insertions(+), 10 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 9a463fd249..e65739eebc 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin: async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" + from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model + source = event.source session_entry = self.session_store.get_or_create_session(source) connected_platforms = [p.value for p in self.adapters.keys()] - # Check if there's an active agent + # Check if there's an active agent. Keep the sentinel distinct: a + # starting/pending run should not be treated as a fully usable agent for + # model/context display, but it still occupies the session slot. session_key = session_entry.session_key - is_running = session_key in self._running_agents + agent = self._running_agents.get(session_key) + is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL # Count pending /queue follow-ups (slot + overflow). adapter = self.adapters.get(source.platform) if source else None queue_depth = self._queue_depth(session_key, adapter=adapter) + def _clean_str(value: Any) -> str: + return value.strip() if isinstance(value, str) and value.strip() else "" + + def _int_value(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + title = None + session_row: dict[str, Any] = {} # Pull token totals from the SQLite session DB rather than the # in-memory SessionStore. The agent's per-turn token deltas are # persisted into sessions_db (run_agent.py), not into SessionEntry, @@ -422,17 +437,106 @@ class GatewaySlashCommandsMixin: title = None try: row = self._session_db.get_session(session_entry.session_id) - if row: + if isinstance(row, dict): + session_row = row db_total_tokens = ( - (row.get("input_tokens") or 0) - + (row.get("output_tokens") or 0) - + (row.get("cache_read_tokens") or 0) - + (row.get("cache_write_tokens") or 0) - + (row.get("reasoning_tokens") or 0) + _int_value(row.get("input_tokens")) + + _int_value(row.get("output_tokens")) + + _int_value(row.get("cache_read_tokens")) + + _int_value(row.get("cache_write_tokens")) + + _int_value(row.get("reasoning_tokens")) ) except Exception: db_total_tokens = 0 + # Resolve model/context for cockpit-style status. Prefer the live or + # cached agent because it carries the actual runtime route and context + # compressor. Fall back to persisted SessionDB metadata plus the + # SessionStore's last_prompt_tokens so /status remains useful between + # turns without making billing/account calls. + status_agent = agent if is_running else None + if status_agent is None: + cache_lock = getattr(self, "_agent_cache_lock", None) + cache = getattr(self, "_agent_cache", None) + if cache_lock is not None and cache is not None: + try: + with cache_lock: + cached = cache.get(session_key) + if cached: + status_agent = cached[0] + except Exception: + status_agent = None + + model_name = "" + provider_name = "" + base_url = "" + context_used = 0 + context_total = 0 + if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL: + model_name = _clean_str(getattr(status_agent, "model", "")) + provider_name = _clean_str(getattr(status_agent, "provider", "")) + base_url = _clean_str(getattr(status_agent, "base_url", "")) + ctx = getattr(status_agent, "context_compressor", None) + if ctx is not None: + context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0)) + context_total = _int_value(getattr(ctx, "context_length", 0)) + + model_name = model_name or _clean_str(session_row.get("model")) + provider_name = provider_name or _clean_str(session_row.get("billing_provider")) + base_url = base_url or _clean_str(session_row.get("billing_base_url")) + context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0)) + + user_config: dict[str, Any] = {} + if not model_name or not provider_name or not context_total: + try: + user_config = _load_gateway_config() + except Exception: + user_config = {} + if not model_name: + model_name = _resolve_gateway_model(user_config) + if not provider_name: + model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} + if isinstance(model_cfg, dict): + provider_name = _clean_str(model_cfg.get("provider")) + if not context_total and model_name: + try: + from agent.model_metadata import get_model_context_length + + model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} + configured_context = None + if isinstance(model_cfg, dict): + configured_context = model_cfg.get("context_length") + custom_providers = user_config.get("custom_providers") if isinstance(user_config, dict) else None + context_total = get_model_context_length( + model_name, + base_url=base_url, + api_key="", + config_context_length=configured_context if isinstance(configured_context, int) else None, + provider=provider_name, + custom_providers=custom_providers if isinstance(custom_providers, list) else None, + ) + except Exception: + context_total = 0 + + model_line = "" + if model_name: + if provider_name: + model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name) + else: + model_line = t("gateway.status.model", model=model_name) + + context_line = "" + if context_total: + pct = min(100, round((context_used / context_total) * 100)) if context_total else 0 + context_line = t( + "gateway.status.context", + used=f"{context_used:,}", + total=f"{context_total:,}", + pct=f"{pct}", + ) + elif context_used: + context_line = t("gateway.status.context_used", used=f"{context_used:,}") + lines = [ t("gateway.status.header"), "", @@ -443,7 +547,13 @@ class GatewaySlashCommandsMixin: lines.extend([ t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')), t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')), - t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"), + ]) + if model_line: + lines.append(model_line) + if context_line: + lines.append(context_line) + lines.extend([ + t("gateway.status.tokens", tokens=f"{db_total_tokens:,} (cumulative)"), t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")), ]) if queue_depth: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7812eba7d5..576eefbf0b 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -109,7 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[text | pause | resume | clear | status]"), CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session", args_hint="[text | remove N | clear]"), - CommandDef("status", "Show session info", "Session"), + CommandDef("status", "Show session, model, token, and context info", "Session"), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", diff --git a/locales/af.yaml b/locales/af.yaml index 7a01f51983..ece46799d9 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Titel:** {title}" created: "**Geskep:** {timestamp}" last_activity: "**Laaste aktiwiteit:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}" agent_running: "**Agent loop:** {state}" state_yes: "Ja ⚡" diff --git a/locales/de.yaml b/locales/de.yaml index f3414c1df3..154268e60d 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Titel:** {title}" created: "**Erstellt:** {timestamp}" last_activity: "**Letzte Aktivität:** {timestamp}" + model: "**Modell:** `{model}`" + model_provider: "**Modell:** `{model}` ({provider})" + context: "**Kontext:** {used} / {total} ({pct}%)" + context_used: "**Kontext:** ~{used} Tokens" tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}" agent_running: "**Agent läuft:** {state}" state_yes: "Ja ⚡" diff --git a/locales/en.yaml b/locales/en.yaml index 00a7654f4f..a8a132622f 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -281,6 +281,10 @@ gateway: title: "**Title:** {title}" created: "**Created:** {timestamp}" last_activity: "**Last Activity:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Cumulative API tokens (re-sent each call):** {tokens}" agent_running: "**Agent Running:** {state}" state_yes: "Yes ⚡" diff --git a/locales/es.yaml b/locales/es.yaml index 96967d9563..9e4d827526 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Título:** {title}" created: "**Creado:** {timestamp}" last_activity: "**Última actividad:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}" agent_running: "**Agente activo:** {state}" state_yes: "Sí ⚡" diff --git a/locales/fr.yaml b/locales/fr.yaml index 6185f79ec5..692c71221f 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Titre :** {title}" created: "**Créé :** {timestamp}" last_activity: "**Dernière activité :** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Jetons :** {tokens}" agent_running: "**Agent en cours :** {state}" state_yes: "Oui ⚡" diff --git a/locales/ga.yaml b/locales/ga.yaml index 752e326605..cdacf94312 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -273,6 +273,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Teideal:** {title}" created: "**Cruthaithe:** {timestamp}" last_activity: "**Gníomhaíocht is déanaí:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Comharthaí:** {tokens}" agent_running: "**Gníomhaire ag rith:** {state}" state_yes: "Tá ⚡" diff --git a/locales/hu.yaml b/locales/hu.yaml index 55d5769836..fec8aac766 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Cím:** {title}" created: "**Létrehozva:** {timestamp}" last_activity: "**Utolsó tevékenység:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Tokenek:** {tokens}" agent_running: "**Ügynök fut:** {state}" state_yes: "Igen ⚡" diff --git a/locales/it.yaml b/locales/it.yaml index 82cf4ce850..5e17a835f4 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Titolo:** {title}" created: "**Creata:** {timestamp}" last_activity: "**Ultima attività:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Token:** {tokens}" agent_running: "**Agente in esecuzione:** {state}" state_yes: "Sì ⚡" diff --git a/locales/ja.yaml b/locales/ja.yaml index 4aeee2a4cf..b6d9a95758 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**タイトル:** {title}" created: "**作成日時:** {timestamp}" last_activity: "**最終アクティビティ:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**トークン:** {tokens}" agent_running: "**エージェント実行中:** {state}" state_yes: "はい ⚡" diff --git a/locales/ko.yaml b/locales/ko.yaml index 8af6b28fe7..f07d22837a 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**제목:** {title}" created: "**생성됨:** {timestamp}" last_activity: "**최종 활동:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**토큰:** {tokens}" agent_running: "**에이전트 실행 중:** {state}" state_yes: "예 ⚡" diff --git a/locales/pt.yaml b/locales/pt.yaml index 69bdb14a9b..5be22d90b1 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Título:** {title}" created: "**Criada:** {timestamp}" last_activity: "**Última atividade:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}" agent_running: "**Agente em execução:** {state}" state_yes: "Sim ⚡" diff --git a/locales/ru.yaml b/locales/ru.yaml index a105f1e68a..ca5617a4cc 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Название:** {title}" created: "**Создано:** {timestamp}" last_activity: "**Последняя активность:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Токены:** {tokens}" agent_running: "**Агент активен:** {state}" state_yes: "Да ⚡" diff --git a/locales/tr.yaml b/locales/tr.yaml index 49e8fdc454..29bacf36ee 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Başlık:** {title}" created: "**Oluşturuldu:** {timestamp}" last_activity: "**Son etkinlik:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Token:** {tokens}" agent_running: "**Aracı çalışıyor:** {state}" state_yes: "Evet ⚡" diff --git a/locales/uk.yaml b/locales/uk.yaml index 2fa55c14c9..1e20ec7b6c 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**Назва:** {title}" created: "**Створено:** {timestamp}" last_activity: "**Остання активність:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Токени:** {tokens}" agent_running: "**Агент активний:** {state}" state_yes: "Так ⚡" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index fd1729203f..a7aae1adb8 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**標題:** {title}" created: "**建立時間:** {timestamp}" last_activity: "**最近活動:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Token 數:** {tokens}" agent_running: "**代理執行中:** {state}" state_yes: "是 ⚡" diff --git a/locales/zh.yaml b/locales/zh.yaml index 17b74e4688..7f9789ee3b 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another title: "**标题:** {title}" created: "**创建时间:** {timestamp}" last_activity: "**最近活动:** {timestamp}" + model: "**Model:** `{model}`" + model_provider: "**Model:** `{model}` ({provider})" + context: "**Context:** {used} / {total} ({pct}%)" + context_used: "**Context:** ~{used} tokens" tokens: "**Token 数:** {tokens}" agent_running: "**代理运行中:** {state}" state_yes: "是 ⚡" diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 0b88d27180..639beef957 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -174,6 +174,79 @@ async def test_status_command_tokens_zero_when_session_db_row_missing(): assert "**Cumulative API tokens (re-sent each call):** 0" in result +@pytest.mark.asyncio +async def test_status_command_includes_live_agent_model_and_context(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + runner._session_db.get_session.return_value = { + "input_tokens": 1000, + "output_tokens": 250, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "reasoning_tokens": 0, + "model": "openai/gpt-test", + } + running_agent = SimpleNamespace( + model="openai/gpt-test", + provider="openai", + context_compressor=SimpleNamespace( + last_prompt_tokens=12_345, + context_length=100_000, + ), + interrupt=MagicMock(), + ) + runner._running_agents[build_session_key(_make_source())] = running_agent + + result = await runner._handle_message(_make_event("/status")) + + assert "**Model:** `openai/gpt-test` (openai)" in result + assert "**Context:** 12,345 / 100,000 (12%)" in result + assert "**Cumulative API tokens (re-sent each call):** 1,250 (cumulative)" in result + + +@pytest.mark.asyncio +async def test_status_command_includes_persisted_model_and_context_when_agent_not_running(monkeypatch): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + last_prompt_tokens=24_000, + ) + runner = _make_runner(session_entry) + runner._session_db.get_session.return_value = { + "input_tokens": 2000, + "output_tokens": 500, + "cache_read_tokens": 0, + "cache_write_tokens": 0, + "reasoning_tokens": 0, + "model": "openai/gpt-persisted", + "billing_provider": "openai-codex", + "billing_base_url": "https://example.invalid/v1", + } + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 272_000, + ) + + result = await runner._handle_message(_make_event("/status")) + + assert "**Model:** `openai/gpt-persisted` (openai-codex)" in result + assert "**Context:** 24,000 / 272,000 (9%)" in result + assert "**Cumulative API tokens (re-sent each call):** 2,500 (cumulative)" in result + + @pytest.mark.asyncio async def test_agents_command_reports_active_agents_and_processes(monkeypatch): session_key = build_session_key(_make_source()) From 3e7e9b24d40c6ff62e50936ba8b8184ad61da322 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 06:04:36 -0700 Subject: [PATCH 44/92] fix: harden salvaged session and browser improvements Polish salvaged contributor work before PR review: - read browser inactivity timeout from config with documented fallback - skip redundant v10 trigram backfill before v11 FTS rebuild - show delegate_task goals safely in progress previews - show gateway status model/context without redundant token wording - wire gateway /sessions to shared session-listing helpers - map Ravenwolf author emails for release attribution Co-authored-by: Wolfram Ravenwolf Co-authored-by: Amy Ravenwolf --- agent/display.py | 40 +++++++++-- cli.py | 11 ++- gateway/run.py | 3 + gateway/slash_commands.py | 72 ++++++++++++++------ hermes_cli/session_listing.py | 97 +++++++++++++++++++++++++++ hermes_state.py | 9 +-- scripts/release.py | 2 + tests/agent/test_display.py | 35 ++++++++++ tests/gateway/test_resume_command.py | 66 +++++++++++++++++- tests/gateway/test_status_command.py | 40 +++++++++-- tests/test_hermes_state.py | 61 ++++++++++++++++- tests/tools/test_browser_hardening.py | 12 +++- tools/browser_tool.py | 9 ++- 13 files changed, 413 insertions(+), 44 deletions(-) create mode 100644 hermes_cli/session_listing.py diff --git a/agent/display.py b/agent/display.py index 55326b5b01..01267e91ea 100644 --- a/agent/display.py +++ b/agent/display.py @@ -12,6 +12,7 @@ import time from dataclasses import dataclass, field from difflib import unified_diff from pathlib import Path +from typing import Any from utils import safe_json_loads from agent.tool_result_classification import file_mutation_result_landed @@ -168,6 +169,27 @@ def _oneline(text: str) -> str: return " ".join(text.split()) +def _truncate_preview(text: str, max_len: int | None) -> str: + if max_len and max_len > 0 and len(text) > max_len: + if max_len <= 3: + return "." * max_len + return text[:max_len - 3] + "..." + return text + + +def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]: + if not isinstance(tasks, list): + return 0, [] + goals: list[str] = [] + for task in tasks: + if not isinstance(task, dict): + continue + raw_goal = task.get("goal") + goal = "?" if raw_goal is None else _oneline(str(raw_goal)) + goals.append(_truncate_preview(goal or "?", per_goal_len)) + return len(goals), goals + + def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None: """Build a short preview of a tool call's primary argument for display. @@ -195,10 +217,17 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) - if tool_name == "delegate_task": tasks = args.get("tasks") if tasks and isinstance(tasks, list): - goals = [_oneline(t.get("goal", "?"))[:40] for t in tasks if isinstance(t, dict)] - return f"{len(tasks)} tasks: " + " | ".join(goals) if goals else f"{len(tasks)} parallel tasks" + task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40) + preview = ( + f"{task_count} tasks: " + " | ".join(goals) + if goals else f"{len(tasks)} parallel tasks" + ) + return _truncate_preview(preview, max_len) goal = args.get("goal", "") - return _oneline(goal) if goal else None + if goal is None: + return None + preview = _oneline(str(goal)) + return _truncate_preview(preview, max_len) if preview else None if tool_name == "process": action = args.get("action", "") @@ -1028,9 +1057,10 @@ def get_cute_tool_message( if tool_name == "delegate_task": tasks = args.get("tasks") if tasks and isinstance(tasks, list): - goals = [_oneline(t.get("goal", "?"))[:30] for t in tasks if isinstance(t, dict)] + task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30) detail = " | ".join(goals) if goals else "parallel" - return _wrap(f"┊ 🔀 delegate {len(tasks)}x: {_trunc(detail, 35)} {dur}") + count_label = task_count or len(tasks) + return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}") return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}") preview = build_tool_preview(tool_name, args) or "" diff --git a/cli.py b/cli.py index 47bca38624..ca01b82d5e 100644 --- a/cli.py +++ b/cli.py @@ -5783,14 +5783,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): if not self._session_db: return [] try: - sessions = self._session_db.list_sessions_rich( + from hermes_cli.session_listing import query_session_listing + + return query_session_listing( + self._session_db, source="cli", - exclude_sources=["tool"], + current_session_id=self.session_id, + include_all_sources=False, + include_unnamed=True, limit=limit, + exclude_sources=["tool"], ) except Exception: return [] - return [s for s in sessions if s.get("id") != self.session_id] def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool: """Render recent sessions inline from the active chat TUI. diff --git a/gateway/run.py b/gateway/run.py index 475320c65a..4541e0fa67 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7554,6 +7554,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "resume": return await self._handle_resume_command(event) + if canonical == "sessions": + return await self._handle_sessions_command(event) + if canonical == "branch": return await self._handle_branch_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index e65739eebc..92db5b42f0 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -498,25 +498,11 @@ class GatewaySlashCommandsMixin: model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} if isinstance(model_cfg, dict): provider_name = _clean_str(model_cfg.get("provider")) - if not context_total and model_name: - try: - from agent.model_metadata import get_model_context_length - - model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} - configured_context = None - if isinstance(model_cfg, dict): - configured_context = model_cfg.get("context_length") - custom_providers = user_config.get("custom_providers") if isinstance(user_config, dict) else None - context_total = get_model_context_length( - model_name, - base_url=base_url, - api_key="", - config_context_length=configured_context if isinstance(configured_context, int) else None, - provider=provider_name, - custom_providers=custom_providers if isinstance(custom_providers, list) else None, - ) - except Exception: - context_total = 0 + if not context_total: + model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {} + configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None + if isinstance(configured_context, int) and configured_context > 0: + context_total = configured_context model_line = "" if model_name: @@ -553,7 +539,7 @@ class GatewaySlashCommandsMixin: if context_line: lines.append(context_line) lines.extend([ - t("gateway.status.tokens", tokens=f"{db_total_tokens:,} (cumulative)"), + t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"), t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")), ]) if queue_depth: @@ -2955,6 +2941,52 @@ class GatewaySlashCommandsMixin: return t("gateway.resume.resumed_one", title=title, count=msg_count) return t("gateway.resume.resumed_many", title=title, count=msg_count) + async def _handle_sessions_command(self, event: MessageEvent) -> str: + """Handle /sessions — list previous sessions for gateway chats.""" + if not self._session_db: + from hermes_state import format_session_db_unavailable + return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix")) + + from hermes_cli.session_listing import ( + format_gateway_session_listing, + parse_session_listing_args, + query_session_listing, + ) + + source = event.source + raw_args = event.get_command_args().strip() + try: + include_all, include_unnamed, target = parse_session_listing_args(raw_args) + except ValueError as exc: + return t("gateway.resume.parse_error", error=exc) + + if target: + resume_event = dataclasses.replace(event, text=f"/resume {target}") + return await self._handle_resume_command(resume_event) + + current_entry = self.session_store.get_or_create_session(source) + rows = query_session_listing( + self._session_db, + source=source.platform.value if source.platform else None, + current_session_id=current_entry.session_id, + include_all_sources=include_all, + include_unnamed=include_unnamed, + limit=10, + exclude_sources=["tool"], + ) + if source.platform == Platform.MATRIX and not include_all: + rows = [ + row for row in rows + if self._same_matrix_room( + source, self._gateway_session_origin_for_id(str(row.get("id") or "")) + ) + ] + return format_gateway_session_listing( + rows, + include_source=include_all, + title="Sessions" if include_unnamed else "Named Sessions", + ) + async def _handle_branch_command(self, event: MessageEvent) -> str: """Handle /branch [name] — fork the current session into a new independent copy. diff --git a/hermes_cli/session_listing.py b/hermes_cli/session_listing.py new file mode 100644 index 0000000000..6ede6a218f --- /dev/null +++ b/hermes_cli/session_listing.py @@ -0,0 +1,97 @@ +"""Shared session-listing helpers for CLI and gateway slash surfaces.""" + +from __future__ import annotations + +from typing import Any + + +def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str]: + """Parse `/sessions`-style args into listing flags plus a resume target. + + Returns ``(include_all_sources, include_unnamed, target)``. ``list``/``ls`` + and ``browse`` are display aliases; ``all``/``--all`` widens source scope; + ``full``/``--full`` keeps unnamed sessions in the listing. Anything else is + treated as a target so `/sessions ` can delegate to `/resume`. + """ + import shlex + + parts = shlex.split(raw_args or "") + include_all = False + include_unnamed = False + target_parts: list[str] = [] + for part in parts: + lower = part.strip().lower() + if lower in {"list", "ls", "browse"}: + continue + if lower in {"all", "--all"}: + include_all = True + continue + if lower in {"full", "--full"}: + include_unnamed = True + continue + target_parts.append(part) + return include_all, include_unnamed, " ".join(target_parts).strip() + + +def query_session_listing( + session_db: Any, + *, + source: str | None, + current_session_id: str | None = None, + include_all_sources: bool = False, + include_unnamed: bool = False, + limit: int = 10, + exclude_sources: list[str] | None = None, +) -> list[dict[str, Any]]: + """Return session rows for interactive listing surfaces. + + This is the shared selection policy behind CLI/gateway session browsing: + source-scoped by default, optionally global, hide unnamed sessions unless + the caller asks for a full listing, and never include the current session. + """ + query_source = None if include_all_sources else source + fetch_limit = max(limit * 4, limit) + rows = session_db.list_sessions_rich( + source=query_source, + exclude_sources=exclude_sources, + limit=fetch_limit, + ) + result: list[dict[str, Any]] = [] + for row in rows: + if current_session_id and row.get("id") == current_session_id: + continue + if not include_unnamed and not row.get("title"): + continue + result.append(row) + if len(result) >= limit: + break + return result + + +def format_gateway_session_listing( + rows: list[dict[str, Any]], + *, + include_source: bool = False, + title: str = "Sessions", +) -> str: + """Render a compact Markdown-ish session list for gateway messengers.""" + if not rows: + return ( + "No sessions found.\n" + "Use `/title My Session` to name this chat, or `/sessions full` " + "to include unnamed sessions." + ) + + lines = [f"📋 **{title}**", ""] + for idx, row in enumerate(rows, start=1): + session_id = str(row.get("id") or "") + title_text = str(row.get("title") or "—") + preview = str(row.get("preview") or "")[:40] + source = str(row.get("source") or "") + source_part = f" `{source}`" if include_source and source else "" + preview_part = f" — _{preview}_" if preview else "" + lines.append(f"{idx}. **{title_text}**{source_part} — `{session_id}`{preview_part}") + lines.append("") + lines.append("Resume: `/resume ` or `/resume ` from `/resume`.") + lines.append("More: `/sessions all`, `/sessions full`, `/sessions all full`.") + return "\n".join(lines) diff --git a/hermes_state.py b/hermes_state.py index 83bc7bf401..8ffe8c25f6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -1123,15 +1123,16 @@ class SessionDB: # backfills, index changes tied to a specific version step) stay # in a version-gated chain. Column additions are handled by # _reconcile_columns() above and no longer need entries here. - if current_version < 10 and SCHEMA_VERSION < 11: + if current_version < 10 and SCHEMA_VERSION == 10: # v10: trigram FTS5 table for CJK/substring search. The # virtual table + triggers are created unconditionally via # FTS_TRIGRAM_SQL below, but existing rows need a one-time # backfill into the FTS index. # - # When upgrading straight to v11+, skip this backfill: v11 - # drops and rebuilds both FTS tables anyway, so doing the v10 - # trigram backfill first only burns startup time and WAL space. + # Only run this when v10 itself is the target schema. Current + # v11+ code drops and rebuilds both FTS tables below, so doing + # the v10-only trigram backfill first only burns startup time + # and WAL space before v11 throws the work away. if fts5_available: _fts_trigram_exists = self._fts_table_probe( cursor, "messages_fts_trigram" diff --git a/scripts/release.py b/scripts/release.py index 4ca3884122..a7461b2179 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -90,6 +90,8 @@ AUTHOR_MAP = { "138671361+Veritas-7@users.noreply.github.com": "Veritas-7", "keiron@onehanded.com": "kmccammon", "268233388+CiarasClaws@users.noreply.github.com": "CiarasClaws", + "amy@ravenwolf.de": "WolframRavenwolf", + "github.com@wolfram.ravenwolf.de": "WolframRavenwolf", "895252509@qq.com": "895252509", "35259607+zxcasongs@users.noreply.github.com": "zxcasongs", "alfred@my-cloud.me": "alfred-smith-0", diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 994aae2864..2e9afd2019 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -104,6 +104,33 @@ class TestBuildToolPreview: assert result is not None assert "find something" in result + def test_delegate_task_single_goal_preview(self): + result = build_tool_preview("delegate_task", {"goal": "Review gateway status"}) + assert result == "Review gateway status" + + def test_delegate_task_batch_goal_preview(self): + result = build_tool_preview( + "delegate_task", + {"tasks": [{"goal": "Review PR A"}, {"goal": "Review PR B"}]}, + ) + assert result == "2 tasks: Review PR A | Review PR B" + + def test_delegate_task_batch_preview_handles_missing_non_string_goals(self): + result = build_tool_preview( + "delegate_task", + {"tasks": [{"goal": None}, {"goal": 123}, "not-a-task"]}, + ) + assert result == "2 tasks: ? | 123" + + def test_delegate_task_batch_preview_respects_max_len(self): + result = build_tool_preview( + "delegate_task", + {"tasks": [{"goal": "A" * 80}, {"goal": "B" * 80}]}, + max_len=30, + ) + assert result == "2 tasks: AAAAAAAAAAAAAAAAAA..." + assert len(result) == 30 + def test_false_like_args_zero(self): """Non-dict falsy values should return None, not crash.""" assert build_tool_preview("terminal", 0) is None @@ -170,6 +197,14 @@ class TestCuteToolMessagePreviewLength: assert "[error]" not in line + def test_delegate_task_batch_message_includes_goals(self): + line = get_cute_tool_message( + "delegate_task", + {"tasks": [{"goal": "Review PR A"}, {"goal": "Review PR B"}]}, + 1.2, + ) + assert "2x: Review PR A | Review PR B" in line + class TestEditDiffPreview: def test_extract_edit_diff_for_patch(self): diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 19f96048e1..a24a8578f4 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -4,7 +4,8 @@ Tests the _handle_resume_command handler (switch to a previously-named session) across gateway messenger platforms. """ -from unittest.mock import MagicMock +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock import pytest @@ -36,9 +37,11 @@ def _make_runner(session_db=None, current_session_id="current_session_001", from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) runner.adapters = {} + runner.config = SimpleNamespace(platforms={}) runner._voice_mode = {} runner._session_db = session_db runner._running_agents = {} + runner._is_user_authorized = lambda _source: True # Compute the real session key if an event is provided session_key = build_session_key(event.source) if event else "agent:main:telegram:dm" @@ -358,3 +361,64 @@ class TestHandleResumeCommand: f"session-id lookup failed: {result!r}" ) db.close() + + + +class TestHandleSessionsCommand: + """Tests for GatewayRunner._handle_sessions_command.""" + + @pytest.mark.asyncio + async def test_sessions_command_lists_current_platform_sessions(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("tg_session", "telegram") + db.set_session_title("tg_session", "Telegram Work") + db.create_session("discord_session", "discord") + db.set_session_title("discord_session", "Discord Work") + + event = _make_event(text="/sessions") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Sessions" in result + assert "Telegram Work" in result + assert "tg_session" in result + assert "Discord Work" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_all_full_lists_cross_platform_unnamed_sessions(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("tg_named", "telegram") + db.set_session_title("tg_named", "Telegram Work") + db.create_session("discord_unnamed", "discord") + db.append_message("discord_unnamed", "user", "discord first prompt") + + event = _make_event(text="/sessions all full") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Telegram Work" in result + assert "discord_unnamed" in result + assert "discord" in result + db.close() + + @pytest.mark.asyncio + async def test_gateway_dispatches_sessions_command(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("tg_session", "telegram") + db.set_session_title("tg_session", "Telegram Work") + + event = _make_event(text="/sessions") + runner = _make_runner(session_db=db, event=event) + runner._handle_sessions_command = AsyncMock(return_value="sessions output") + + result = await runner._handle_message(event) + + assert result == "sessions output" + runner._handle_sessions_command.assert_awaited_once_with(event) + db.close() diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 639beef957..f02738b51f 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -61,6 +61,8 @@ def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.T runner._reasoning_config = None runner._provider_routing = {} runner._fallback_model = None + runner._agent_cache = {} + runner._agent_cache_lock = MagicMock() runner._show_reasoning = False runner._is_user_authorized = lambda _source: True runner._set_session_env = lambda _context: None @@ -209,7 +211,8 @@ async def test_status_command_includes_live_agent_model_and_context(): assert "**Model:** `openai/gpt-test` (openai)" in result assert "**Context:** 12,345 / 100,000 (12%)" in result - assert "**Cumulative API tokens (re-sent each call):** 1,250 (cumulative)" in result + assert "**Cumulative API tokens (re-sent each call):** 1,250" in result + assert "1,250 (cumulative)" not in result @pytest.mark.asyncio @@ -235,16 +238,41 @@ async def test_status_command_includes_persisted_model_and_context_when_agent_no "billing_provider": "openai-codex", "billing_base_url": "https://example.invalid/v1", } - monkeypatch.setattr( - "agent.model_metadata.get_model_context_length", - lambda *_args, **_kwargs: 272_000, - ) + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {"model": {"context_length": 272_000}}) result = await runner._handle_message(_make_event("/status")) assert "**Model:** `openai/gpt-persisted` (openai-codex)" in result assert "**Context:** 24,000 / 272,000 (9%)" in result - assert "**Cumulative API tokens (re-sent each call):** 2,500 (cumulative)" in result + assert "**Cumulative API tokens (re-sent each call):** 2,500" in result + + +@pytest.mark.asyncio +async def test_status_command_includes_cached_agent_model_and_context(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + cached_agent = SimpleNamespace( + model="anthropic/claude-sonnet-test", + provider="openrouter", + context_compressor=SimpleNamespace( + last_prompt_tokens=10_000, + context_length=200_000, + ), + ) + runner._agent_cache = {session_entry.session_key: (cached_agent, time.time())} + + result = await runner._handle_message(_make_event("/status")) + + assert "**Model:** `anthropic/claude-sonnet-test` (openrouter)" in result + assert "**Context:** 10,000 / 200,000 (5%)" in result @pytest.mark.asyncio diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index a1932b650f..f4258f2b91 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4,7 +4,7 @@ import sqlite3 import time import pytest -from hermes_state import SCHEMA_SQL, SessionDB +from hermes_state import SCHEMA_SQL, SCHEMA_VERSION, SessionDB class _NoFtsCursor(sqlite3.Cursor): @@ -2297,6 +2297,65 @@ class TestSchemaInit: migrated_db.close() + def test_v9_migration_skips_v10_trigram_backfill_before_v11_rebuild(self, tmp_path, monkeypatch): + """Direct v9→current migration should do only the v11 FTS rebuild. + + v10 backfilled ``messages_fts_trigram`` with content-only rows. Current + v11+ migration immediately drops and rebuilds both FTS tables with + content + tool metadata, so running the v10 insert first is wasted work. + """ + db_path = tmp_path / "v9_fts.db" + conn = sqlite3.connect(str(db_path)) + conn.executescript(SCHEMA_SQL) + conn.execute("DELETE FROM schema_version") + conn.execute("INSERT INTO schema_version (version) VALUES (9)") + conn.execute( + "INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)", + ("s1", "cli", 1000.0), + ) + conn.execute( + "INSERT INTO messages (session_id, role, content, tool_name, tool_calls, timestamp) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("s1", "tool", "plain content", "browser_snapshot", '{"name":"browser_snapshot"}', 1001.0), + ) + conn.commit() + conn.close() + + trigram_content_only_inserts = [] + real_connect = sqlite3.connect + + def connect_with_trace(*args, **kwargs): + conn = real_connect(*args, **kwargs) + + def trace(sql): + text = " ".join(str(sql).split()) + if ( + "INSERT INTO messages_fts_trigram" in text + and "SELECT id, content FROM messages" in text + ): + trigram_content_only_inserts.append(text) + + conn.set_trace_callback(trace) + return conn + + monkeypatch.setattr("hermes_state.sqlite3.connect", connect_with_trace) + migrated_db = SessionDB(db_path=db_path) + try: + assert trigram_content_only_inserts == [] + version = migrated_db._conn.execute("SELECT version FROM schema_version").fetchone()[0] + assert version == SCHEMA_VERSION + normal_count = migrated_db._conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] + trigram_count = migrated_db._conn.execute("SELECT COUNT(*) FROM messages_fts_trigram").fetchone()[0] + assert normal_count == 1 + assert trigram_count == 1 + tool_hit = migrated_db._conn.execute( + "SELECT COUNT(*) FROM messages_fts_trigram " + "WHERE messages_fts_trigram MATCH 'browser_snapshot'" + ).fetchone()[0] + assert tool_hit == 1 + finally: + migrated_db.close() + def test_reconciliation_adds_missing_columns(self, tmp_path): """Columns present in SCHEMA_SQL but missing from the live table are added by _reconcile_columns regardless of schema_version. diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py index 23ff9c93b9..cf1197eae6 100644 --- a/tests/tools/test_browser_hardening.py +++ b/tests/tools/test_browser_hardening.py @@ -117,11 +117,12 @@ class TestCommandTimeoutCache: class TestSessionInactivityTimeout: - def test_default_is_300(self, monkeypatch): + def test_default_matches_config_default(self, monkeypatch): + from hermes_cli.config import DEFAULT_CONFIG from tools.browser_tool import _get_session_inactivity_timeout monkeypatch.delenv("BROWSER_INACTIVITY_TIMEOUT", raising=False) with patch("hermes_cli.config.read_raw_config", return_value={}): - assert _get_session_inactivity_timeout() == 300 + assert _get_session_inactivity_timeout() == DEFAULT_CONFIG["browser"]["inactivity_timeout"] def test_reads_from_config_over_env(self, monkeypatch): from tools.browser_tool import _get_session_inactivity_timeout @@ -137,6 +138,13 @@ class TestSessionInactivityTimeout: with patch("hermes_cli.config.read_raw_config", return_value=cfg): assert _get_session_inactivity_timeout() == 30 + def test_invalid_config_preserves_env_fallback(self, monkeypatch): + from tools.browser_tool import _get_session_inactivity_timeout + monkeypatch.setenv("BROWSER_INACTIVITY_TIMEOUT", "240") + cfg = {"browser": {"inactivity_timeout": "not-an-int"}} + with patch("hermes_cli.config.read_raw_config", return_value=cfg): + assert _get_session_inactivity_timeout() == 240 + # --------------------------------------------------------------------------- # Caching: _discover_homebrew_node_dirs diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 2c56bf9bb7..ee597d50c0 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -67,7 +67,7 @@ from pathlib import Path from agent.auxiliary_client import call_llm from hermes_constants import get_hermes_home from utils import env_int, is_truthy_value -from hermes_cli.config import cfg_get +from hermes_cli.config import DEFAULT_CONFIG, cfg_get try: from tools.website_policy import check_website_access @@ -1180,8 +1180,13 @@ _cleanup_done = False # Session inactivity timeout (seconds) - cleanup if no activity for this long. # config.yaml is authoritative; BROWSER_INACTIVITY_TIMEOUT remains a legacy # fallback so old deployments keep working if they have not migrated yet. +DEFAULT_SESSION_INACTIVITY_TIMEOUT = int( + DEFAULT_CONFIG.get("browser", {}).get("inactivity_timeout", 120) +) + + def _get_session_inactivity_timeout() -> int: - result = env_int("BROWSER_INACTIVITY_TIMEOUT", 300) + result = env_int("BROWSER_INACTIVITY_TIMEOUT", DEFAULT_SESSION_INACTIVITY_TIMEOUT) try: from hermes_cli.config import read_raw_config cfg = read_raw_config() From eae3836eb661732b4f3be88231a21d7a2fd66702 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 22:20:23 +0700 Subject: [PATCH 45/92] fix(desktop): pin @assistant-ui/store so the cluster shares one tap The desktop app is built from source on every install/update (install.ps1 -> npm ci/install -> tsc -b && vite build). The @assistant-ui packages share an internal reactivity lib, @assistant-ui/tap, and only interoperate when they all resolve the SAME tap version. @assistant-ui/react@0.12.28 and @assistant-ui/core pin tap@^0.5.x (which exports only "." and "./react"), but the caret range react -> store@^0.2.9 floated store up to 0.2.18, which bumped its tap peer to ^0.9.0 and began importing "@assistant-ui/tap/react-shim" -- an entry point that only exists in the tap 0.9.x line. With the hoisted tap stuck on 0.5.x, vite build crashed: "./react-shim" is not exported ... from package @assistant-ui/tap i.e. the opaque "apps/desktop build failed (exit 1)" everyone hit when updating today. Pin @assistant-ui/store via root overrides to 0.2.13 -- the last release that targets tap@^0.5.x -- so react/core/store all agree on the hoisted tap@0.5.14 again. Verified: tsc -b and vite build both pass. --- package-lock.json | 8 ++++---- package.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d8a5f51ff..5658a67959 100644 --- a/package-lock.json +++ b/package-lock.json @@ -387,15 +387,15 @@ } }, "node_modules/@assistant-ui/store": { - "version": "0.2.18", - "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.18.tgz", - "integrity": "sha512-5MiZXAXjsZuH3ZVEemuiD5L8wq/pXax8lSlaIsdTPEkDZDFupsiDwuOeum+h+ctX8H8oKgkCpN4iPUIiiLKuVg==", + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.13.tgz", + "integrity": "sha512-7NL6HWMBxe1ndLWO4kHkjQ0Syyc0D/Aj+zxdpcy4yrplG71X04CzFimMBBSQAk+AnGBf+d96D7cuUZdjHkTavg==", "license": "MIT", "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { - "@assistant-ui/tap": "^0.9.0", + "@assistant-ui/tap": "^0.5.14", "@types/react": "*", "react": "^18 || ^19" }, diff --git a/package.json b/package.json index 13689e75c0..eebd955e41 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "agent-browser": "^0.26.0" }, "overrides": { - "lodash": "4.18.1" + "lodash": "4.18.1", + "@assistant-ui/store": "0.2.13" }, "engines": { "node": ">=20.0.0" From f02484feba6d3bb35fa00be6e1d16de8e26e12ab Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 22:20:30 +0700 Subject: [PATCH 46/92] test(deps): guard @assistant-ui cluster on one tap version Lockfile invariant that would have caught the desktop build break: the single hoisted @assistant-ui/tap must satisfy every @assistant-ui/* package's declared tap requirement (deps or non-optional peer). It is a contract, not a snapshot -- no hardcoded versions -- so it stays green across routine bumps but fails the moment the cluster splits its tap requirement again. --- tests/test_assistant_ui_tap_compat.py | 141 ++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/test_assistant_ui_tap_compat.py diff --git a/tests/test_assistant_ui_tap_compat.py b/tests/test_assistant_ui_tap_compat.py new file mode 100644 index 0000000000..57c9e87481 --- /dev/null +++ b/tests/test_assistant_ui_tap_compat.py @@ -0,0 +1,141 @@ +"""Invariant: the @assistant-ui dependency cluster agrees on one tap version. + +The Hermes desktop app (``apps/desktop``) is built from source on every +install/update via ``scripts/install.ps1`` → ``npm ci``/``npm install`` → +``tsc -b && vite build``. The ``@assistant-ui`` packages share an internal +reactivity lib, ``@assistant-ui/tap``, and they only interoperate when they +all resolve the *same* tap version: + +* ``@assistant-ui/react@0.12.28`` and ``@assistant-ui/core`` pin + ``@assistant-ui/tap@^0.5.x`` (which exports ``.`` and ``./react``). +* ``@assistant-ui/store@0.2.18`` bumped its tap peer to ``^0.9.0`` and started + importing ``@assistant-ui/tap/react-shim`` — an entry point that only exists + in the tap ``0.9.x`` line. + +Because ``react@0.12.28`` requests ``store@^0.2.9`` (a caret range), a fresh +install silently floated ``store`` up to ``0.2.18``, which then could not find +``./react-shim`` in the hoisted ``tap@0.5.x`` and crashed ``vite build`` with:: + + "./react-shim" is not exported ... from package @assistant-ui/tap + +i.e. the opaque "apps/desktop build failed (exit 1)" every user hit when +updating. The fix pins ``@assistant-ui/store`` (via root ``overrides``) to the +last release that targets ``tap@^0.5.x``. + +This is a *contract* test, not a snapshot: it does not assert specific version +numbers, only that whatever tap the lockfile hoists satisfies every +``@assistant-ui/*`` package's declared tap requirement. It fails if any future +bump reintroduces a split tap requirement across the cluster. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +TAP = "@assistant-ui/tap" + + +def _caret_satisfies(version: str, spec: str) -> bool: + """Minimal npm semver check for the ranges this cluster actually uses. + + Supports exact versions, ``^x.y.z`` (with correct 0.x semantics), and + ``||`` unions. Pre-release tags are ignored (none are used here). + """ + + def parse(v: str) -> tuple[int, int, int]: + core = v.lstrip("^~>= 0: + hi = (major + 1, 0, 0) + elif minor > 0: + hi = (0, minor + 1, 0) + else: + hi = (0, 0, lo[2] + 1) + if ver < hi: + return True + elif clause[0].isdigit() or clause.startswith("v"): + if ver == parse(clause): + return True + return False + + +def _lock_packages() -> dict: + lock_path = REPO_ROOT / "package-lock.json" + if not lock_path.exists(): + pytest.skip("package-lock.json not materialized in this CI shard") + with lock_path.open("r", encoding="utf-8") as fh: + return json.load(fh).get("packages", {}) + + +def _hoisted_tap_version(packages: dict) -> str: + entry = packages.get(f"node_modules/{TAP}") + assert entry is not None, ( + "package-lock.json has no hoisted node_modules/@assistant-ui/tap " + "entry — the @assistant-ui cluster should resolve a single shared " + "tap version." + ) + return entry["version"] + + +def test_assistant_ui_cluster_agrees_on_one_tap() -> None: + """Every @assistant-ui/* package's tap requirement must be satisfiable. + + Encodes the contract that broke the desktop build: a single hoisted + @assistant-ui/tap must satisfy the tap range declared by react, core, + store, and any sibling — otherwise the missing ``./react-shim`` export + (or a similar API split) breaks ``vite build``. + """ + packages = _lock_packages() + tap_version = _hoisted_tap_version(packages) + + offenders: list[str] = [] + for key, meta in packages.items(): + name = key.rsplit("node_modules/", 1)[-1] + if not name.startswith("@assistant-ui/") or name == TAP: + continue + peer_meta = meta.get("peerDependenciesMeta", {}).get(TAP, {}) + if peer_meta.get("optional"): + continue + spec = meta.get("dependencies", {}).get(TAP) or meta.get( + "peerDependencies", {} + ).get(TAP) + if not spec: + continue + if not _caret_satisfies(tap_version, spec): + offenders.append(f"{name} requires {TAP}{spec!r}") + + assert not offenders, ( + f"Hoisted {TAP}@{tap_version} does not satisfy: " + + "; ".join(offenders) + + ". The @assistant-ui cluster has split tap requirements — pin the " + "offending package (e.g. via root package.json `overrides`) so the " + "whole cluster shares one tap line. See this test's module docstring." + ) + + +def test_caret_satisfies_helper() -> None: + """Guard the tiny semver helper the invariant relies on.""" + assert _caret_satisfies("0.5.14", "^0.5.10") + assert _caret_satisfies("0.5.14", "^0.5.14") + assert not _caret_satisfies("0.5.14", "^0.9.0") + assert not _caret_satisfies("0.5.14", "^0.6.0") + assert _caret_satisfies("1.2.5", "^1.2.0") + assert not _caret_satisfies("2.0.0", "^1.2.0") + assert _caret_satisfies("0.5.14", "^0.5.0 || ^0.9.0") From 30377e108ca86861e832a2644879b30125debefd Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 22:34:40 +0700 Subject: [PATCH 47/92] ci(desktop): build the renderer on PRs so vite breaks fail in CI The desktop build break shipped because nothing in CI runs the apps/desktop production build. typecheck only runs `tsc`, which does not exercise Vite/Rolldown module resolution, so an unresolvable package export (the @assistant-ui/tap "./react-shim" split) sailed through green checks and only failed when users built from source on install/update. Add a desktop-build job that runs `npm run build` (tsc -b + vite build + assert-dist-built) for apps/desktop. This closes the gap so the same class of break fails in CI instead of on every user's machine. --- .github/workflows/typecheck.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f3dcc71efd..e21b80864c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -23,3 +23,20 @@ jobs: cache: npm - run: npm ci - run: npm run --prefix ${{ matrix.package }} typecheck + + # Production build of the desktop renderer. `typecheck` runs `tsc` only, + # which does NOT exercise Vite/Rolldown module resolution — so an + # unresolvable package export (e.g. a transitive @assistant-ui/tap that no + # longer exports "./react-shim") slips past typecheck and only explodes when + # users build apps/desktop from source on install/update. Run the real + # `vite build` here so that class of break fails in CI instead. + desktop-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run --prefix apps/desktop build From 45e2f4fdcd760b0072eecb32b7b9c00b62c17b98 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 22:44:07 +0700 Subject: [PATCH 48/92] nix: refresh npmDepsHash for the @assistant-ui/store pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store pin changed package-lock.json, so the workspace-wide npmDepsHash in nix/lib.nix is stale and the Nix flake check fails on the hash mismatch. Use the hash reported by the real fetchNpmDeps build (the flake check's `got:`), which is authoritative — it differs from prefetch-npm-deps' lockfile-contents hash, exactly the divergence nix/lib.nix already documents. --- nix/lib.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib.nix b/nix/lib.nix index df5898004f..dea1d48b4c 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-C7eu7WkT0z2XTey/2tnjg7vVBw9XhQMSDhFkUzT/+HI="; + npmDepsHash = "sha256-m9cjbjzi4SaFCjODfdrawS5e+1ag+MpRn528/upSNqo="; npmDeps = pkgs.fetchNpmDeps { inherit src; From 9eb0bcd60fc6d3fe28e0b1c5bbe188f94169f65c Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 15 Jun 2026 12:04:04 -0400 Subject: [PATCH 49/92] change(ci): rip out nix ci for now to be re-added later when we have more stable ci flows --- .github/workflows/nix-lockfile-fix.yml | 255 ------------------------- .github/workflows/nix.yml | 105 ---------- 2 files changed, 360 deletions(-) delete mode 100644 .github/workflows/nix-lockfile-fix.yml delete mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml deleted file mode 100644 index b83b0ba3d3..0000000000 --- a/.github/workflows/nix-lockfile-fix.yml +++ /dev/null @@ -1,255 +0,0 @@ -name: Nix Lockfile Fix - -on: - push: - branches: [main] - paths: - - 'package-lock.json' - - 'package.json' - - 'ui-tui/package.json' - - 'apps/desktop/package.json' - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to fix (leave empty to run on the selected branch)' - required: false - type: string - issue_comment: - types: [edited] - -permissions: - contents: write - pull-requests: write - -concurrency: - group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }} - cancel-in-progress: false - -jobs: - # ── Auto-fix on main ─────────────────────────────────────────────── - # Fires when a push to main touches package.json or package-lock.json. - # Runs fix-lockfiles and pushes the hash update commit directly to main - # so Nix builds never stay broken. - # - # Safety invariants: - # 1. The fix commit only touches nix/*.nix files, which are NOT in - # the paths filter above, so this cannot re-trigger itself. - # 2. An explicit file-whitelist check before commit aborts if - # fix-lockfiles ever modifies unexpected files. - # 3. Job-level concurrency with cancel-in-progress: true ensures - # back-to-back pushes collapse to the newest; ref: main checkout - # always operates on the latest branch state. - # 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit - # triggers downstream nix.yml verification. - auto-fix-main: - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 25 - concurrency: - group: auto-fix-main - cancel-in-progress: true - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: main - token: ${{ steps.app-token.outputs.token }} - - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Apply lockfile hashes - id: apply - run: nix run .#fix-lockfiles -- --apply - - - name: Commit & push - if: steps.apply.outputs.changed == 'true' - shell: bash - run: | - set -euo pipefail - - # Ensure only nix/lib.nix (home of the single npmDepsHash) was - # modified — prevents accidental self-triggering if fix-lockfiles - # ever touches package files. - unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)" - if [ -n "$unexpected" ]; then - echo "::error::Unexpected modified files: $unexpected" - exit 1 - fi - - # Record the base SHA before committing — used to detect package - # file changes if we need to rebase after a non-fast-forward push. - BASE_SHA="$(git rev-parse HEAD)" - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/lib.nix - git commit -m "fix(nix): auto-refresh npm lockfile hashes" \ - -m "Source: $GITHUB_SHA" \ - -m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - - # Retry push with rebase in case main advanced with an unrelated - # commit during the nix build. Without this, a non-fast-forward - # rejection silently loses the fix. If package files changed during - # the rebase, abort — a fresh auto-fix run will handle the new state. - for attempt in 1 2 3; do - if git push origin HEAD:main; then - exit 0 - fi - echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…" - git fetch origin main - - # If package files changed between our base and the new main, - # our computed hashes are stale. Abort and let the next triggered - # run recompute from the correct package-lock state. - pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \ - 'package-lock.json' 'package.json' \ - 'ui-tui/package.json' 'apps/desktop/package.json' || true)" - if [ -n "$pkg_changed" ]; then - echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute" - exit 0 - fi - - git rebase origin/main - done - echo "::error::Failed to push after 3 rebase attempts" - exit 1 - - # ── PR fix (manual / checkbox) ───────────────────────────────────── - # Existing behavior: run on manual dispatch OR when a task-list - # checkbox in the sticky lockfile-check comment flips from [ ] to [x]. - fix: - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' - && github.event.issue.pull_request != null - && contains(github.event.comment.body, '[x] **Apply lockfile fix**') - && !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**')) - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Authorize & resolve PR - id: resolve - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - // 1. Verify the actor has write access — applies to both checkbox - // clicks and manual dispatch. - const { data: perm } = - await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.actor, - }); - if (!['admin', 'write', 'maintain'].includes(perm.permission)) { - core.setFailed( - `${context.actor} lacks write access (has: ${perm.permission})` - ); - return; - } - - // 2. Resolve which ref to check out. - let prNumber = ''; - if (context.eventName === 'issue_comment') { - prNumber = String(context.payload.issue.number); - } else if (context.eventName === 'workflow_dispatch') { - prNumber = context.payload.inputs.pr_number || ''; - } - - if (!prNumber) { - core.setOutput('ref', context.ref.replace(/^refs\/heads\//, '')); - core.setOutput('repo', context.repo.repo); - core.setOutput('owner', context.repo.owner); - core.setOutput('pr', ''); - return; - } - - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: Number(prNumber), - }); - core.setOutput('ref', pr.head.ref); - core.setOutput('repo', pr.head.repo.name); - core.setOutput('owner', pr.head.repo.owner.login); - core.setOutput('pr', String(pr.number)); - - # Wipe the sticky lockfile-check comment to a "running" state as soon - # as the job is authorized, so the user sees their click was picked up - # before the ~minute of nix build work. - - name: Mark sticky as running - if: steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### 🔄 Applying lockfile fix… - - Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }} - ref: ${{ steps.resolve.outputs.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Apply lockfile hashes - id: apply - run: nix run .#fix-lockfiles - - - name: Commit & push - if: steps.apply.outputs.changed == 'true' - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/lib.nix - git commit -m "fix(nix): refresh npm lockfile hashes" - git push - - - name: Update sticky (applied) - if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ✅ Lockfile fix applied - - Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - name: Update sticky (already current) - if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ✅ Lockfile hashes already current - - Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - name: Update sticky (failed) - if: failure() && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ❌ Lockfile fix failed - - See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs. diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index b6590f0a01..0000000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Nix - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - pull-requests: write - -concurrency: - group: nix-${{ github.ref }} - cancel-in-progress: true - -jobs: - nix: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Resolve head SHA - if: github.event_name == 'pull_request' - id: sha - shell: bash - run: | - FULL="${{ github.event.pull_request.head.sha || github.sha }}" - echo "full=$FULL" >> "$GITHUB_OUTPUT" - echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT" - - - name: Check flake - id: flake - continue-on-error: true - run: nix flake check --print-build-logs - - # When the flake check fails, run a targeted diagnostic to see if - # the failure is specifically a stale npm lockfile hash in one of the - # known npm subpackages (tui / web). This avoids surfacing a generic - # "build failed" message when the fix is a single known command. - - name: Diagnose npm lockfile hashes - id: hash_check - if: steps.flake.outcome == 'failure' && runner.os == 'Linux' - continue-on-error: true - env: - LINK_SHA: ${{ steps.sha.outputs.full }} - run: nix run .#fix-lockfiles -- --check - - # If fix-lockfiles itself crashes (infrastructure blip, cache throttle, - # etc.) it won't set stale=true/false. Treat that as a distinct failure - # mode rather than silently ignoring it. - - name: Fail if hash check crashed without reporting - if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false' - run: | - echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure" - exit 1 - - - name: Post sticky PR comment (stale hashes) - if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - message: | - ### ⚠️ npm lockfile hash out of date - - Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time). - - The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`: - - ${{ steps.hash_check.outputs.report }} - - #### Apply the fix - - - [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch - - Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`) - - Or locally: `nix run .#fix-lockfiles` and commit the diff - - # Clear the sticky comment when either the flake check passed outright (no - # hash check needed) or the hash check explicitly returned stale=false - # (check failed for a non-hash reason). - - name: Clear sticky PR comment (resolved) - if: | - github.event_name == 'pull_request' && - (steps.hash_check.outputs.stale == 'false' || - steps.flake.outcome == 'success') - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - delete: true - - - name: Final fail if flake check failed - if: steps.flake.outcome == 'failure' - run: | - if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then - echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles" - else - echo "::error::Nix flake check failed. See logs above." - fi - exit 1 From ae433634db562e644175d39537ef6b811a381f3f Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 15 Jun 2026 11:57:03 -0400 Subject: [PATCH 50/92] fix(desktop): move tsconfig to es2023 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ibrahim özsaraç <160004724+iborazzi@users.noreply.github.com> --- apps/bootstrap-installer/tsconfig.json | 4 ++-- apps/shared/tsconfig.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/bootstrap-installer/tsconfig.json b/apps/bootstrap-installer/tsconfig.json index 9227970f06..b5638bda53 100644 --- a/apps/bootstrap-installer/tsconfig.json +++ b/apps/bootstrap-installer/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", diff --git a/apps/shared/tsconfig.json b/apps/shared/tsconfig.json index 4e530c70d9..c602eeebca 100644 --- a/apps/shared/tsconfig.json +++ b/apps/shared/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2023"], "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, From 0bbff1fc7e132c7464986483b50e7048e68255b3 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 12:44:44 -0400 Subject: [PATCH 51/92] fix(deps): declare websockets as core dep + relax dev setuptools pin (salvage #45486, #44693) (#46744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: declare websockets as a core dependency * fix(deps): relax dev setuptools pin 82.0.1 -> 81.0.0 (torch caps setuptools<82) torch >= 2.11 publishes Requires-Dist: setuptools<82, so any environment that resolves the dev extra together with torch is unsatisfiable: $ uv pip install --dry-run ".[dev]" "torch==2.12.0" x No solution found when resolving dependencies: ... torch==2.12.0 and all versions of hermes-agent[dev] are incompatible. 81.0.0 is the latest release under the cap and stays inside the declared build-system window (setuptools>=77.0,<83). uv.lock regenerated with 'uv lock'; diff is scoped to the setuptools entry. Co-Authored-By: Claude Fable 5 * chore: map salvaged contributor emails for attribution Add AUTHOR_MAP entries for the two cherry-picked contributors so the check-attribution CI gate passes: - yehaotian@xuanshudeMac-mini.local -> ArcanePivot (#45486) - dbeyer7@gmail.com -> benegessarit (#44693) --------- Co-authored-by: 玄枢 Co-authored-by: David Beyer Co-authored-by: Claude Fable 5 Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- pyproject.toml | 5 ++++- scripts/release.py | 2 ++ tools/browser_cdp_tool.py | 6 +++--- uv.lock | 10 ++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf90868007..9520d49610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ dependencies = [ # (which is a silent killer on Windows — see CONTRIBUTING.md) and # `os.killpg` (which doesn't exist on Windows). "psutil==7.2.2", + # Browser CDP supervisor + browser_dialog import this directly. Keep core + # so browser tool discovery doesn't fail on lean installs. + "websockets==15.0.1", # .gitignore-aware file matching for desktop build stamp. "pathspec==1.1.1", "fastapi>=0.104.0,<1", @@ -132,7 +135,7 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] hindsight = ["hindsight-client==0.6.1"] -dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710 +dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"] diff --git a/scripts/release.py b/scripts/release.py index a7461b2179..5058e406cd 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", + "dbeyer7@gmail.com": "benegessarit", "kenmege@yahoo.com": "Kenmege", "tianying.x@eukarya.io": "xtymac", "dkobi16@gmail.com": "Diyoncrz18", diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index e13264767d..da20e30117 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -28,9 +28,9 @@ logger = logging.getLogger(__name__) CDP_DOCS_URL = "https://chromedevtools.github.io/devtools-protocol/" -# ``websockets`` is a transitive dependency of hermes-agent (via fal_client -# and firecrawl-py) and is already imported by gateway/platforms/feishu.py. -# Wrap the import so a clean error surfaces if the package is ever absent. +# ``websockets`` is a direct hermes-agent dependency because the browser CDP +# supervisor and browser_dialog tool import it during tool discovery. Wrap the +# import so a clean error surfaces if an environment is stale or incomplete. try: import websockets from websockets.exceptions import WebSocketException diff --git a/uv.lock b/uv.lock index 804a1628c0..8694951168 100644 --- a/uv.lock +++ b/uv.lock @@ -1419,6 +1419,7 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "urllib3" }, { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, ] [package.optional-dependencies] @@ -1676,7 +1677,7 @@ requires-dist = [ { name = "rich", specifier = "==14.3.3" }, { name = "ruamel-yaml", specifier = "==0.18.17" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" }, - { name = "setuptools", marker = "extra == 'dev'", specifier = "==82.0.1" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==81.0.0" }, { name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" }, { name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" }, { name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" }, @@ -1693,6 +1694,7 @@ requires-dist = [ { name = "urllib3", specifier = ">=2.7.0,<3" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, + { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] @@ -3571,11 +3573,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "81.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] [[package]] From 0bbf325a8f50cbc7cfc74886705e8a67bb521dad Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 12:50:19 -0400 Subject: [PATCH 52/92] fix(dashboard): scope chat sidebar model card to selected profile (#46665) * fix(dashboard): scope chat sidebar model card to selected profile The PTY already honors ?profile= on profile switch, but the JSON-RPC sidecar created sessions against the dashboard launch profile. Pass the management profile through session.create and reconnect on switch. Co-authored-by: Cursor * fix(dashboard): sync active profile with management scope Align the sidebar switcher with the sticky active profile on load and when "Set as active" is clicked, so Chat and management pages match what the Profiles page shows as active. Co-authored-by: Cursor * fix(dashboard): auto-reconnect chat sidebar on profile switch Bump the sidecar connection version when profile or PTY channel changes, matching the manual Reconnect path so gateway and events sockets come back without clicking the error banner. Co-authored-by: Cursor * fix(dashboard): prevent model selector chevron overlapping label Use inline flex layout instead of Button suffix, which is absolutely positioned and overlapped truncated model names at px-0. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- ...t_dashboard_sidecar_close_on_disconnect.py | 12 +++++ web/src/components/ChatSidebar.tsx | 54 +++++++++++++++---- web/src/contexts/ProfileProvider.tsx | 44 +++++++++++---- web/src/pages/ChatPage.tsx | 4 +- web/src/pages/ProfilesPage.tsx | 9 ++-- 5 files changed, 94 insertions(+), 29 deletions(-) diff --git a/tests/test_dashboard_sidecar_close_on_disconnect.py b/tests/test_dashboard_sidecar_close_on_disconnect.py index bb11e688cf..b3490900d4 100644 --- a/tests/test_dashboard_sidecar_close_on_disconnect.py +++ b/tests/test_dashboard_sidecar_close_on_disconnect.py @@ -11,3 +11,15 @@ def test_sidecar_session_create_requests_close_on_disconnect(): call = re.search(r'"session\.create",\s*\{(.*?)\}', source, re.DOTALL) assert call, "sidecar session.create call not found" assert re.search(r"close_on_disconnect:\s*true", call.group(1)) + + +def test_sidecar_session_create_scopes_profile(): + """The sidecar must pass the dashboard's selected profile so model/credential + info matches the PTY child under profile-scoped chat.""" + source = CHAT_SIDEBAR.read_text(encoding="utf-8") + assert '"session.create"' in source + assert re.search( + r"close_on_disconnect:\s*true,\s*\.\.\.\(profile\s*\?\s*\{\s*profile\s*\}\s*:\s*\{\}\)", + source, + re.DOTALL, + ) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 66b15b95f9..1a53741d8f 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -34,7 +34,7 @@ import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface SessionInfo { cwd?: string; @@ -71,10 +71,12 @@ const STATE_TONE: Record< interface ChatSidebarProps { channel: string; + /** Management profile from the dashboard switcher — scopes session.create. */ + profile?: string; className?: string; } -export function ChatSidebar({ channel, className }: ChatSidebarProps) { +export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) { // `version` bumps on reconnect; gw is derived so we never call setState // for it inside an effect (React 19's set-state-in-effect rule). The // counter is the dependency on purpose — it's not read in the memo body, @@ -90,8 +92,29 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { const [modelOpen, setModelOpen] = useState(false); const [error, setError] = useState(null); + // Profile or PTY channel change tears down both WebSockets. Bump `version` + // (same path as the manual Reconnect button) so the gateway client is + // recreated and the events feed resubscribes — otherwise the old events + // socket's close handler can leave a stale error banner after a switch. + const scopeKey = `${channel}\0${profile ?? ""}`; + const prevScopeKey = useRef(null); + useEffect(() => { + if (prevScopeKey.current === null) { + prevScopeKey.current = scopeKey; + return; + } + if (prevScopeKey.current === scopeKey) return; + prevScopeKey.current = scopeKey; + setError(null); + setTools([]); + setVersion((v) => v + 1); + }, [scopeKey]); + useEffect(() => { let cancelled = false; + setSessionId(null); + setInfo({}); + setError(null); const offState = gw.onState(setState); const offSessionInfo = gw.on("session.info", (ev) => { @@ -124,6 +147,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { // slash_worker subprocess) when the WS drops, instead of leaking it. return gw.request<{ session_id: string }>("session.create", { close_on_disconnect: true, + ...(profile ? { profile } : {}), }); }) .then((created) => { @@ -145,6 +169,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { offError(); gw.close(); }; + // `profile` is read from render; scope changes bump `version` → new `gw`. }, [gw]); // Event subscriber WebSocket — receives the rebroadcast of every @@ -304,7 +329,7 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { )} > -
+
model
@@ -314,19 +339,26 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { size="sm" disabled={!canPickModel} onClick={() => setModelOpen(true)} - suffix={ - canPickModel ? ( - - ) : undefined - } - className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline" + className={cn( + "max-w-full min-w-0 px-0 py-0", + "self-start normal-case tracking-normal text-sm font-medium", + "hover:underline disabled:no-underline", + )} title={info.model ?? "switch model"} > - {modelLabel} + + {modelLabel} + + {canPickModel ? ( + + ) : null} +
- {STATE_LABEL[state]} + + {STATE_LABEL[state]} + {banner && ( diff --git a/web/src/contexts/ProfileProvider.tsx b/web/src/contexts/ProfileProvider.tsx index 0beedb49bc..91c5440e41 100644 --- a/web/src/contexts/ProfileProvider.tsx +++ b/web/src/contexts/ProfileProvider.tsx @@ -26,10 +26,12 @@ import { ProfileContext } from "@/contexts/profile-context"; * 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. + * This exists because "Set as active" on the Profiles page historically only + * flipped the sticky active_profile file (future CLI/gateway runs). The + * switcher is the dashboard's write-target selector for Chat and management + * pages. We now sync the switcher when the sticky active profile differs from + * the dashboard process on load, and ProfilesPage updates the switcher when + * you click "Set as active". */ export function ProfileProvider({ children }: { children: ReactNode }) { const [searchParams, setSearchParams] = useSearchParams(); @@ -77,14 +79,34 @@ export function ProfileProvider({ children }: { children: ReactNode }) { }, [pathname, profile]); useEffect(() => { - api - .getProfiles() - .then((res) => setProfiles(res.profiles.map((p) => p.name))) - .catch(() => {}); - api - .getActiveProfile() - .then((info) => setCurrentProfile(info.current || "default")) + let cancelled = false; + const urlProfile = searchParams.get("profile"); + + Promise.all([api.getProfiles(), api.getActiveProfile()]) + .then(([profilesRes, info]) => { + if (cancelled) return; + + setProfiles(profilesRes.profiles.map((p) => p.name)); + + const current = info.current || "default"; + const active = info.active || "default"; + setCurrentProfile(current); + + // Deep links (?profile=) win. Otherwise align the switcher with the + // sticky active profile so Chat and management pages match what the + // Profiles page shows as "active" (machine dashboard runs as + // `current`, usually default). + if (urlProfile === null && active !== current) { + setManagementProfile(active); + setProfileState(active); + } + }) .catch(() => {}); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const setProfile = useCallback( diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 3497503553..b8c1ecbcbf 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -861,7 +861,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { "border-t border-current/10", )} > - +
, @@ -929,7 +929,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80" >
- +
)} diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index fdf89fa4a4..781ca0c778 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -7,6 +7,7 @@ import { useState, } from "react"; import { useNavigate } from "react-router-dom"; +import { useProfileScope } from "@/contexts/useProfileScope"; import { AlignLeft, Check, @@ -259,6 +260,7 @@ export default function ProfilesPage() { const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); + const { setProfile } = useProfileScope(); // Locale strings with English fallbacks. The enriched keys are optional in // the i18n type so untranslated locales don't break the build — they render @@ -305,7 +307,7 @@ export default function ProfilesPage() { manageSkills: p.manageSkills ?? "Manage skills & tools", activeSetHint: p.activeSetHint ?? - "Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.", + "Dashboard switched to manage {name}. New CLI/gateway runs will use this profile too.", }; }, [t.profiles]); @@ -495,10 +497,7 @@ export default function ProfilesPage() { // The backend normalizes/validates the name; trust the canonical // value it returns rather than the raw input. const { active } = await api.setActiveProfile(name); - // "Set as active" only flips the sticky default for FUTURE CLI/gateway - // invocations — it does NOT retarget this running dashboard. Say so, - // or users assume skill/tool toggles now apply to the activated - // profile (they don't — that's what "Manage skills & tools" is for). + setProfile(active); showToast( `${L.activeSet}: ${active} — ${L.activeSetHint.replace("{name}", active)}`, "success", From 5f6be7f31bd7ef53f92d030b060325783f84f169 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 14:35:15 -0400 Subject: [PATCH 53/92] fix(teams): package Microsoft Teams SDK as an installable extra (salvage #43945) (#46764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(teams): package Microsoft Teams SDK as an installable extra The Teams adapter imports the microsoft-teams-apps SDK, but it was never declared as a dependency, so source/local installs hit ImportError and the adapter silently reported the SDK as unavailable. Add a 'teams' extra (microsoft-teams-apps==2.0.13.4 + aiohttp) and document 'uv sync --extra teams'. Per the 2026-05-12 [all] policy, opt-in messaging-platform SDKs are NOT added to [all] (they would break every fresh install on a quarantined release); the teams extra is installed on demand like the other platform backends. Co-authored-by: rio-jeong * chore: map rio-jeong contributor email for attribution (#43945) * feat(teams): lazy-install the Teams SDK on demand (parity with other channels) The teams extra alone left Teams as the only messaging platform that wouldn't auto-install its SDK — every other channel (telegram, discord, slack, matrix, dingtalk, feishu) lazy-installs via tools.lazy_deps on first connect. Bring Teams to parity: - Add 'platform.teams' to LAZY_DEPS (microsoft-teams-apps + aiohttp). - Replace the passive 'check_teams_requirements = check_requirements' alias with a real lazy-installer that calls ensure_and_bind('platform.teams', ...), rebinding all Teams SDK globals on success (mirrors check_slack_requirements). - Call check_teams_requirements() at the top of TeamsAdapter.connect() so enabling Teams installs the SDK on demand. - Keep the passive check_requirements() as the registry check_fn so 'gateway status' probes never trigger a pip install. The 'teams' extra remains for packagers / explicit 'uv sync --extra teams'. Tests: rework the alias test into shortcircuit + lazy-install assertions, and update test_connect_fails_without_sdk to simulate an uninstallable SDK. --------- Co-authored-by: rio-jeong Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- plugins/platforms/teams/adapter.py | 74 +++++++++++++++++- pyproject.toml | 1 + scripts/release.py | 1 + tests/gateway/test_teams.py | 38 +++++++++- tools/lazy_deps.py | 5 ++ uv.lock | 87 +++++++++++++++++++++- website/docs/user-guide/messaging/teams.md | 9 +++ 7 files changed, 211 insertions(+), 4 deletions(-) diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index a7d024419e..f8175a6a62 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -617,7 +617,74 @@ async def _standalone_send( # Keep the old name as an alias so existing test imports don't break. -check_teams_requirements = check_requirements +# NOTE: ``check_requirements`` is the PASSIVE probe (used as the registry +# ``check_fn`` and by ``gateway status``) — it must never trigger a pip +# install. ``check_teams_requirements`` is the ACTIVE lazy-installer called +# from ``connect()``; it installs ``platform.teams`` on demand and rebinds the +# SDK globals, mirroring ``check_slack_requirements`` in gateway/platforms/slack.py. +def check_teams_requirements() -> bool: + """Ensure the Teams SDK is importable, lazy-installing it on first use. + + Lazy-installs ``microsoft-teams-apps`` via + ``tools.lazy_deps.ensure("platform.teams")`` if not present, then rebinds + all module-level SDK globals on success. Returns True once the SDK (and + aiohttp) are importable, False if they couldn't be installed/imported. + """ + if TEAMS_SDK_AVAILABLE and AIOHTTP_AVAILABLE: + return True + + def _import() -> dict: + from aiohttp import web as _web + from microsoft_teams.apps import App, ActivityContext + from microsoft_teams.common.http.client import ClientOptions + from microsoft_teams.api import MessageActivity, ConversationReference + from microsoft_teams.api.activities.typing import TypingActivityInput + from microsoft_teams.api.activities.invoke.adaptive_card import ( + AdaptiveCardInvokeActivity, + ) + from microsoft_teams.api.models.adaptive_card import ( + AdaptiveCardActionCardResponse, + AdaptiveCardActionMessageResponse, + ) + from microsoft_teams.api.models.invoke_response import ( + InvokeResponse, + AdaptiveCardInvokeResponse, + ) + from microsoft_teams.apps.http.adapter import ( + HttpMethod, + HttpRequest, + HttpResponse, + HttpRouteHandler, + ) + from microsoft_teams.cards import AdaptiveCard, ExecuteAction, TextBlock + + return { + "web": _web, + "AIOHTTP_AVAILABLE": True, + "App": App, + "ActivityContext": ActivityContext, + "ClientOptions": ClientOptions, + "MessageActivity": MessageActivity, + "ConversationReference": ConversationReference, + "TypingActivityInput": TypingActivityInput, + "AdaptiveCardInvokeActivity": AdaptiveCardInvokeActivity, + "AdaptiveCardActionCardResponse": AdaptiveCardActionCardResponse, + "AdaptiveCardActionMessageResponse": AdaptiveCardActionMessageResponse, + "InvokeResponse": InvokeResponse, + "AdaptiveCardInvokeResponse": AdaptiveCardInvokeResponse, + "HttpMethod": HttpMethod, + "HttpRequest": HttpRequest, + "HttpResponse": HttpResponse, + "HttpRouteHandler": HttpRouteHandler, + "AdaptiveCard": AdaptiveCard, + "ExecuteAction": ExecuteAction, + "TextBlock": TextBlock, + "TEAMS_SDK_AVAILABLE": True, + } + + from tools.lazy_deps import ensure_and_bind + + return ensure_and_bind("platform.teams", _import, globals(), prompt=False) class TeamsAdapter(BasePlatformAdapter): @@ -642,10 +709,13 @@ class TeamsAdapter(BasePlatformAdapter): self._conv_refs: Dict[str, Any] = {} async def connect(self) -> bool: + # Lazy-install the Teams SDK on demand (parity with Slack/Discord/etc.), + # then re-check the module globals it rebinds. + check_teams_requirements() if not TEAMS_SDK_AVAILABLE: self._set_fatal_error( "MISSING_SDK", - "microsoft-teams-apps not installed. Run: pip install microsoft-teams-apps", + "microsoft-teams-apps could not be installed. Run: pip install microsoft-teams-apps", retryable=False, ) return False diff --git a/pyproject.toml b/pyproject.toml index 9520d49610..4a2ab1c6b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,7 @@ mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 nemo-relay = ["nemo-relay==0.3"] homeassistant = ["aiohttp==3.13.4"] sms = ["aiohttp==3.13.4"] +teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"] # Computer use — macOS background desktop control via cua-driver (MCP stdio). # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk diff --git a/scripts/release.py b/scripts/release.py index 5058e406cd..318c8c82d2 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "rio.jeong@thebytesize.ai": "rio-jeong", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", "kenmege@yahoo.com": "Kenmege", diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index d4f56104a6..1ae10593cc 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -211,10 +211,39 @@ class TestTeamsRequirements: monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) assert check_requirements() is True - def test_alias_matches(self, monkeypatch): + def test_check_teams_requirements_shortcircuits_when_present(self, monkeypatch): + # When the SDK + aiohttp are already importable, the active lazy- + # installer returns True immediately without attempting an install. monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", True) monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", True) + called = {"ensure_and_bind": 0} + + def _fake_ensure_and_bind(*_args, **_kwargs): + called["ensure_and_bind"] += 1 + return True + + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + ) assert check_teams_requirements() is True + assert called["ensure_and_bind"] == 0 + + def test_check_teams_requirements_lazy_installs_when_missing(self, monkeypatch): + # When deps are missing, the active installer delegates to + # ensure_and_bind("platform.teams", ...) — parity with Slack/Discord. + monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + monkeypatch.setattr(_teams_mod, "AIOHTTP_AVAILABLE", False) + seen = {} + + def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + seen["feature"] = feature + return True + + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", _fake_ensure_and_bind + ) + assert check_teams_requirements() is True + assert seen["feature"] == "platform.teams" def test_validate_config_with_env(self, monkeypatch): monkeypatch.setenv("TEAMS_CLIENT_ID", "test-id") @@ -371,6 +400,13 @@ class TestTeamsConnect: @pytest.mark.anyio async def test_connect_fails_without_sdk(self, monkeypatch): monkeypatch.setattr(_teams_mod, "TEAMS_SDK_AVAILABLE", False) + # Simulate the SDK being unavailable AND not installable (offline / + # locked-down env): the lazy-installer can't rebind the globals, so + # TEAMS_SDK_AVAILABLE stays False and connect() must fail. + monkeypatch.setattr( + "tools.lazy_deps.ensure_and_bind", + lambda *_a, **_k: False, + ) adapter = TeamsAdapter(_make_config( client_id="id", client_secret="secret", tenant_id="tenant", )) diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index e4b0a9a57f..cb123caaf9 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -152,6 +152,11 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { # defusedxml only; aiohttp/httpx are core dependencies of every messaging # adapter and ship via `platform.discord` / `platform.slack` / etc. "platform.wecom_callback": ("defusedxml==0.7.1",), + # Microsoft Teams adapter — microsoft-teams-apps pulls a heavy tree + # (microsoft-teams-api/cards/common, dependency-injector, msal). Lazy- + # installed on demand like every other messaging platform; also exposed + # as the `teams` extra in pyproject for packagers / explicit installs. + "platform.teams": ("microsoft-teams-apps==2.0.13.4", "aiohttp==3.13.4"), # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), diff --git a/uv.lock b/uv.lock index 8694951168..385cffe0dd 100644 --- a/uv.lock +++ b/uv.lock @@ -960,6 +960,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "dependency-injector" +version = "4.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/be/26bb530d06618fb0bb34244d46b0d0ccc53d0974e680d8653f1b1b313a0e/dependency_injector-4.49.0.tar.gz", hash = "sha256:17a04dbfaa8159f1dc068fc26bc2fa0af9774cdd87f99e3b61bd74c9e7171589", size = 1168930, upload-time = "2026-03-22T21:20:05.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/5d/cc49fb34e0c03aa56d7583de00e2f8f5aa1b8a878b695e970dcdb751a477/dependency_injector-4.49.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:9690192fd5aed07f21dfdfae07696fef12c68bf98e4c0e1af8f8128b255a74a7", size = 1769395, upload-time = "2026-03-22T21:19:14.163Z" }, + { url = "https://files.pythonhosted.org/packages/7f/97/b3b144c96e1f7fff0a7e2e83eb0767bd23b6bacffd0ac8cff397d350e94d/dependency_injector-4.49.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f91f2a191bdb17bd3068f32fe65f04128bc162c6237ea554c117b303c22aaabb", size = 1852089, upload-time = "2026-03-22T21:19:16.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e7/33061f427bcb56c8936d5db464d757d926bf752a874683fb64b2ee225463/dependency_injector-4.49.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:733c0d88b26be17a48e5741cc3e3956080112e40c07a38ff38e99dfa772f9772", size = 1765608, upload-time = "2026-03-22T21:19:19.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/4d/2751a6c055de4a200d65af297ecd926d6b6107f66f3849e8122928abf461/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:45720b30a2a3df6e5e2320e242f6dd94540ba27c3da57cafdc37fdeec59d5ce3", size = 1746555, upload-time = "2026-03-22T21:19:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/02/6f/f74fee9629528f0879295b9f89a5c751d3ad931eca0c78407f715e5472a6/dependency_injector-4.49.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b5d2f1be2dc971db47b1305a83b5a8c24d0eba7fb4cea7845679f9c9f24a0a9", size = 1843223, upload-time = "2026-03-22T21:19:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f0/45948c7c933f063039a44afb4bd61747a7bafd50693e6ccdc972fac0839c/dependency_injector-4.49.0-cp310-abi3-win32.whl", hash = "sha256:0593c8aaade651a5a88ff8ba1271a8364773e76d3aa2efbeacc3be4969cafd1c", size = 1546172, upload-time = "2026-03-22T21:19:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b5/1d8e5627137cb9a6812ecaa468eaf39154f6605c5088da4749e5a8579483/dependency_injector-4.49.0-cp310-abi3-win_amd64.whl", hash = "sha256:fa4b587158b0d65a1f9681ca648da3f9bf90f312f68c2f2e73cc58296ec2bf45", size = 1674743, upload-time = "2026-03-22T21:19:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/35/ca21ab897fc193dcdbad1f856361e7614b8e2b69f9f9351e9a87a3c58e51/dependency_injector-4.49.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6c4b49df30f13f5e4361719b21c79445db11869a7a00d80a0486c03fd764ba8f", size = 1744444, upload-time = "2026-03-22T21:19:57.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/44/d108aeee8f2edd3e725ac0e32d16e4339a034a07da9ddaf07f772f425140/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b0c637ba230e390631da13bb80c955a9f85487f78c9772c0f6a3b50bfbff3a6", size = 1822320, upload-time = "2026-03-22T21:19:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/32/6243ef32c384dda156b053c3df5c8b6c3ac42250ec089a09915f015d38a1/dependency_injector-4.49.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5857b2672512654110dd0371fa965b98255e2f0507dd4732a066767b72e23c4", size = 1741215, upload-time = "2026-03-22T21:20:01.523Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/a1957d4ef87a52c13f2b790c1cc5fae17eb385fbe2e978c7fd8c1ebb4ea9/dependency_injector-4.49.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8ffa2ac9297446f73bd28ada81aadf4494a52d869159d58923435bcd88b5ef60", size = 1652017, upload-time = "2026-03-22T21:20:03.653Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -1542,6 +1564,10 @@ slack = [ sms = [ { name = "aiohttp" }, ] +teams = [ + { name = "aiohttp" }, + { name = "microsoft-teams-apps" }, +] termux = [ { name = "agent-client-protocol" }, { name = "honcho-ai" }, @@ -1591,6 +1617,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.13.4" }, { name = "aiohttp", marker = "extra == 'slack'", specifier = "==3.13.4" }, { name = "aiohttp", marker = "extra == 'sms'", specifier = "==3.13.4" }, + { name = "aiohttp", marker = "extra == 'teams'", specifier = "==3.13.4" }, { name = "aiohttp-socks", marker = "extra == 'matrix'", specifier = "==0.11.0" }, { name = "aiosqlite", marker = "extra == 'matrix'", specifier = "==0.22.1" }, { name = "alibabacloud-dingtalk", marker = "extra == 'dingtalk'", specifier = "==2.2.42" }, @@ -1649,6 +1676,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" }, + { name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" }, { name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" }, { name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" }, { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" }, @@ -1697,7 +1725,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -2176,6 +2204,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "microsoft-teams-api" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-teams-cards" }, + { name = "microsoft-teams-common" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/7f/dc1995f72a8d23e723b168db20bac67b819ef2fa734bc23f63bc8086c41b/microsoft_teams_api-2.0.13.4.tar.gz", hash = "sha256:d16f88ae90f65bcce83ede9ecc57773f7b1a19cbecde63be624b586b59e34fc9", size = 51779, upload-time = "2026-06-08T19:24:02.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/e1a1369a22c265b52da3ac4b3ee67b5c02911300db045894868bd7be932f/microsoft_teams_api-2.0.13.4-py3-none-any.whl", hash = "sha256:be52ef7765ea5851e0982de1ff6b1192869c85fc74e890ae20029bd99064b532", size = 149825, upload-time = "2026-06-08T19:24:13.202Z" }, +] + +[[package]] +name = "microsoft-teams-apps" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "dependency-injector" }, + { name = "fastapi" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-common" }, + { name = "msal" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dotenv" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/0a/733f05f8decee2da6e53ee38757e742520ae56363bc2d006b309cdbf9cfe/microsoft_teams_apps-2.0.13.4.tar.gz", hash = "sha256:d0b12e5e82024cffd3739b329b098b98a08803753eb5484bf96dbb6ce1237e04", size = 91366, upload-time = "2026-06-08T19:24:04.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d4/3c4205258642035d160c09f598a302260776dcb6d5bdf659eea7c6066d5e/microsoft_teams_apps-2.0.13.4-py3-none-any.whl", hash = "sha256:db16f714ec658b592929c6386a29792e90bb73840732f8ae65a198cda1fea96c", size = 71406, upload-time = "2026-06-08T19:24:15.034Z" }, +] + +[[package]] +name = "microsoft-teams-cards" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7f/cce9633f635d9e1b2318ce2146a804a14a46c9e34e855c3784beb8ab39b3/microsoft_teams_cards-2.0.13.4.tar.gz", hash = "sha256:de54956a2afbbcf187f2531459967515b4f4743fa784bd0f454eaff1ac675c90", size = 28108, upload-time = "2026-06-08T19:24:07.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/09/95cad44d4417e33df11a15c82ca1bde442c1f1f77396f936f18896f116c1/microsoft_teams_cards-2.0.13.4-py3-none-any.whl", hash = "sha256:b8b887466c8144675ff5704064daf05ec3ebdf4d322658ab9a25bfc1373d7909", size = 29617, upload-time = "2026-06-08T19:24:17.373Z" }, +] + +[[package]] +name = "microsoft-teams-common" +version = "2.0.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/f1/a32821cfdde6c0d33a1e4022492a2211af670a81ec1fab727c49cddd4f7a/microsoft_teams_common-2.0.13.4.tar.gz", hash = "sha256:ed3175316f77f083a500da0a84ddf53ac31c6de008a252f0cfd86bdb70120bf3", size = 11122, upload-time = "2026-06-08T19:24:09.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/04/859b3d7fadd1d61ab581f79afb6125c16c60cecf2a2e6bbb2ebbcfd34f80/microsoft_teams_common-2.0.13.4-py3-none-any.whl", hash = "sha256:19524ec75587d797d07c5a78e9b72921b6d58f33d39512ca2d33468160fd0d82", size = 16588, upload-time = "2026-06-08T19:24:18.325Z" }, +] + [[package]] name = "mistralai" version = "2.4.8" diff --git a/website/docs/user-guide/messaging/teams.md b/website/docs/user-guide/messaging/teams.md index ae30d4a585..bc59ca342e 100644 --- a/website/docs/user-guide/messaging/teams.md +++ b/website/docs/user-guide/messaging/teams.md @@ -24,6 +24,15 @@ Teams delivers @mentions as regular messages with `BotName` tags, which --- +For source or local installs, include the Teams extra so the bundled adapter can +import the Microsoft Teams SDK: + +```bash +uv sync --extra teams +# or, for editable installs: +uv pip install -e ".[teams]" +``` + ## Step 1: Install the Teams CLI The `@microsoft/teams.cli` automates bot registration — no Azure portal needed. From f3b32e9f52204ad654d4c43b364a8bfa4a32b520 Mon Sep 17 00:00:00 2001 From: ChasLui Date: Thu, 4 Jun 2026 11:22:58 +0800 Subject: [PATCH 54/92] fix(desktop): restore Electron binary before macOS pack rename (salvage #38673) electron-builder 26.8.x can stage an Electron.app without its Contents/MacOS/Electron binary, then fail renaming it to Hermes: ENOENT: no such file or directory, rename .../MacOS/Electron -> .../MacOS/Hermes This breaks `npm run pack` and the installer desktop stage before a launchable Hermes.app exists. - Point build.electronDist at the already-installed Electron dist so electron-builder reuses it instead of re-unpacking from cache. - Add a darwin-only prebuilder patch that restores the missing main binary from the runtime dist before the rename. Idempotent (marker guard), soft-fails on shape mismatch, survives node_modules reinstall. Co-authored-by: ChasLui --- apps/desktop/package.json | 2 + .../patch-electron-builder-mac-binary.cjs | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 apps/desktop/scripts/patch-electron-builder-mac-binary.cjs diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 52be586f01..ebc9293668 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "start": "npm run build && electron .", "build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild", "postbuild": "node scripts/assert-dist-built.cjs", + "prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs", "builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder", "pack": "npm run build && npm run builder -- --dir", "dist": "npm run build && npm run builder", @@ -134,6 +135,7 @@ }, "build": { "electronVersion": "40.9.3", + "electronDist": "../../node_modules/electron/dist", "appId": "com.nousresearch.hermes", "productName": "Hermes", "executableName": "Hermes", diff --git a/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs b/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs new file mode 100644 index 0000000000..38315b9c65 --- /dev/null +++ b/apps/desktop/scripts/patch-electron-builder-mac-binary.cjs @@ -0,0 +1,59 @@ +const fs = require('node:fs') +const path = require('node:path') + +if (process.platform !== 'darwin') { + process.exit(0) +} + +const desktopRoot = path.resolve(__dirname, '..') +const repoRoot = path.resolve(desktopRoot, '..', '..') +const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js') + +const marker = 'hermes-macos-electron-binary-fallback' +const needle = ` await Promise.all([ + doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")), + ]);` +const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy + // Electron.app without its main MacOS/Electron binary before this rename. + // Restore it from the installed Electron runtime so local desktop installs + // do not fail with ENOENT during macOS arm64 packaging. + const macosDir = path.join(contentsPath, "MacOS"); + const bundledElectronBinary = path.join(macosDir, electronBranding.productName); + if (!fs.existsSync(bundledElectronBinary)) { + const candidates = [ + path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName), + path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName), + ]; + const sourceBinary = candidates.find(candidate => fs.existsSync(candidate)); + if (sourceBinary == null) { + throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary); + } + await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary); + await (0, promises_1.chmod)(bundledElectronBinary, 0o755); + } + await Promise.all([ + doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")), + (0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")), + ]);` + +if (!fs.existsSync(electronMacPath)) { + console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`) + process.exit(0) +} + +const source = fs.readFileSync(electronMacPath, 'utf8') +if (source.includes(marker)) { + console.log('[patch-electron-builder] macOS Electron binary fallback already applied') + process.exit(0) +} + +if (!source.includes(needle)) { + console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found') + process.exit(0) +} + +fs.writeFileSync(electronMacPath, source.replace(needle, replacement)) +console.log('[patch-electron-builder] applied macOS Electron binary fallback') From c23a2eec15617c209e0ad28d3b78f41afa74dcd1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 13:53:23 -0500 Subject: [PATCH 55/92] chore: map salvaged contributor email for attribution (#38673) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 318c8c82d2..cdebc8e10a 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "chaslui@outlook.com": "ChasLui", "rio.jeong@thebytesize.ai": "rio-jeong", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", From f7c1cbe66ffa34b9ede451d096d54c1062fec666 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 15:02:24 -0400 Subject: [PATCH 56/92] docs: point desktop download links to site root (deprecate /desktop) (#46795) The /desktop page is deprecated and redirects to the home page. The landing page for the desktop app is now simply https://hermes-agent.nousresearch.com/. Update all docs and the Docusaurus nav/footer links accordingly. Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com> --- apps/desktop/README.md | 2 +- website/docs/getting-started/installation.md | 2 +- website/docs/getting-started/quickstart.md | 2 +- website/docs/guides/run-nemotron-3-ultra-free.md | 2 +- website/docs/index.mdx | 4 ++-- website/docusaurus.config.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 301b094592..17d1cacee5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke ### Prebuilt installers -Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop). +Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/). --- diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index 09884fa831..2cef841fe5 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -10,7 +10,7 @@ Get Hermes Agent up and running in under two minutes! ## Quick Install ### With the Hermes Desktop installer on macOS or Windows (recommended) -To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it. ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 04a6322664..630df6e293 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -48,7 +48,7 @@ Pick the row that matches your goal: ## 1. Install Hermes Agent ### With the Hermes Desktop installer on macOS or Windows (recommended) -To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) from our website and run it. ### Without Hermes Desktop: For a command-line only install without Hermes Desktop, run: diff --git a/website/docs/guides/run-nemotron-3-ultra-free.md b/website/docs/guides/run-nemotron-3-ultra-free.md index 0192fe105a..f50ec0f594 100644 --- a/website/docs/guides/run-nemotron-3-ultra-free.md +++ b/website/docs/guides/run-nemotron-3-ultra-free.md @@ -20,7 +20,7 @@ The simplest path: a one-click installer with a guided, point-and-click setup. N ### 1. Download and install -[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) for macOS or Windows, then open it. On first launch it finishes setting itself up (usually under a minute). +[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/) for macOS or Windows, then open it. On first launch it finishes setting itself up (usually under a minute). ### 2. Connect Nous Portal diff --git a/website/docs/index.mdx b/website/docs/index.mdx index ce7effcbf7..ea4499f91e 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -36,7 +36,7 @@ The self-improving AI agent built by [Nous Research](https://nousresearch.com). Get Started → Date: Sun, 31 May 2026 00:18:45 +0800 Subject: [PATCH 57/92] fix(doctor): recognize nvidia as vendor-slug-accepting provider NVIDIA NIM API uses vendor-prefixed model IDs (e.g. qwen/qwen3.5-122b-a10b, nvidia/nemotron-3-super-120b-a12b). The doctor command incorrectly warns that vendor-prefixed slugs belong to aggregators like openrouter when nvidia is the configured provider. Add 'nvidia' to the providers_accepting_vendor_slugs set so doctor no longer raises false-positive warnings for valid NVIDIA NIM configurations. Fixes #35425 --- hermes_cli/doctor.py | 1 + tests/hermes_cli/test_doctor.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 79c41b03f1..127adefb39 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -796,6 +796,7 @@ def run_doctor(args): "huggingface", "lmstudio", "nous", + "nvidia", } provider_accepts_vendor_slug = ( provider_policy_id in providers_accepting_vendor_slugs diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index c9b2dad062..ba2032b8ef 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -493,6 +493,7 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon ("opencode-zen", "anthropic/claude-sonnet-4.6"), ("kilocode", "anthropic/claude-sonnet-4.6"), ("kimi-coding", "kimi-k2"), + ("nvidia", "qwen/qwen3.5-122b-a10b"), ], ) def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( @@ -533,7 +534,7 @@ def test_run_doctor_accepts_hermes_provider_ids_that_catalog_aliases( out = buf.getvalue() assert f"model.provider '{provider}' is not a recognised provider" not in out assert f"model.provider '{provider}' is unknown" not in out - if provider in {"opencode-zen", "kilocode"}: + if provider in {"opencode-zen", "kilocode", "nvidia"}: assert ( f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider}'" not in out From 60cc42e38bf6570766b4cc24a8ac673aebb783c7 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 14 Jun 2026 13:03:59 +0800 Subject: [PATCH 58/92] fix(inventory): deduplicate models between user-defined and aggregator providers When a user-defined provider (e.g. litellm-proxy) and an aggregator (e.g. openrouter) both advertise the same model name, the Desktop/TUI model picker would show the model under both groups. Selecting it from the aggregator row silently set model.provider to the aggregator, breaking calls because the aggregator doesn't actually serve that model ID. Fix: after list_authenticated_providers() returns, collect all models from user-defined provider rows and filter them out of aggregator rows. Uses is_aggregator() from hermes_cli/providers.py to identify aggregators. Case-insensitive matching. Fixes #45954 --- hermes_cli/inventory.py | 30 +++++++ tests/hermes_cli/test_inventory.py | 124 +++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 48fc4e928d..43d3150ccd 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -157,6 +157,36 @@ def build_models_payload( max_models=max_models, ) + # --- Deduplicate: remove models from aggregators that overlap with + # user-defined providers. When a local proxy (e.g. litellm-proxy) + # serves a model whose name also appears in an aggregator's curated + # catalog, the picker would show the model under both providers. + # Selecting it from the aggregator row sets model.provider to the + # aggregator (e.g. openrouter) instead of the user's proxy — silently + # breaking the call. Filtering at the payload level keeps the + # aggregator rows honest: they only show models the user can't get + # from a more-specific provider. (#45954) + try: + from hermes_cli.providers import is_aggregator as _is_aggregator + except Exception: + _is_aggregator = None # type: ignore[assignment] + + if _is_aggregator is not None: + user_models: set[str] = set() + for row in rows: + if row.get("is_user_defined"): + user_models.update(m.lower() for m in (row.get("models") or [])) + if user_models: + for row in rows: + slug = row.get("slug", "") + if not _is_aggregator(slug): + continue + original = row.get("models") or [] + filtered = [m for m in original if m.lower() not in user_models] + if len(filtered) < len(original): + row["models"] = filtered + row["total_models"] = len(filtered) + if include_unconfigured: rows = list(rows) + _append_unconfigured_rows(rows, ctx) if picker_hints: diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index e51c62a270..e81288f9ab 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -482,3 +482,127 @@ def test_payload_shape_compatible_with_modelpickerdialog_frontend(): for row in payload["providers"]: missing = required_keys - row.keys() assert not missing, f"row {row['slug']} missing keys: {missing}" + + +# ─── Aggregator dedup (issue #45954) ─────────────────────────────────── + + +def _user_provider_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": True, + "source": "user-config", + } + + +def _aggregator_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": False, + "source": "built-in", + } + + +def test_aggregator_dedup_removes_overlapping_models(): + """Models served by a user-defined provider are removed from + aggregator rows so the picker doesn't show them under the wrong + provider. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", [ + "nvidia/nim/minimax-m3", + "nvidia/nim/kimi-k2.6", + ]), + _aggregator_row("openrouter", [ + "minimax/minimax-m3", + "nvidia/nim/minimax-m3", # overlaps with litellm-proxy + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + proxy_row = next(r for r in payload["providers"] if r["slug"] == "litellm-proxy") + + # User-defined provider keeps all its models + assert proxy_row["models"] == ["nvidia/nim/minimax-m3", "nvidia/nim/kimi-k2.6"] + + # Aggregator lost the overlapping model but kept the rest + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert "minimax/minimax-m3" in or_row["models"] + assert "anthropic/claude-sonnet-4.6" in or_row["models"] + assert or_row["total_models"] == 2 + + +def test_aggregator_dedup_case_insensitive(): + """Dedup uses case-insensitive matching. (#45954)""" + rows = [ + _user_provider_row("my-proxy", ["NVIDIA/NIM/MiniMax-M3"]), + _aggregator_row("openrouter", ["nvidia/nim/minimax-m3", "other/model"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_overlap_unchanged(): + """When there's no overlap, aggregator models are untouched. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", ["custom/model-a"]), + _aggregator_row("openrouter", ["anthropic/claude-sonnet-4.6"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["anthropic/claude-sonnet-4.6"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_user_providers_unchanged(): + """When there are no user-defined providers, nothing is filtered. + (#45954)""" + rows = [ + _aggregator_row("openrouter", [ + "nvidia/nim/minimax-m3", + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = payload["providers"][0] + assert len(or_row["models"]) == 2 + + +def test_aggregator_dedup_multiple_user_providers(): + """Models from all user-defined providers are excluded from aggregators. + (#45954)""" + rows = [ + _user_provider_row("proxy-a", ["model-x"]), + _user_provider_row("proxy-b", ["model-y"]), + _aggregator_row("openrouter", ["model-x", "model-y", "model-z"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["model-z"] + assert or_row["total_models"] == 1 + From b2a4766463a74ee1500ead5178d5e04731b54787 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 19:09:49 +0700 Subject: [PATCH 59/92] fix(dump): report effective terminal backend in `hermes debug` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `terminal.backend` in config.yaml is bridged to the TERMINAL_ENV env var, but a TERMINAL_ENV set in .env / the shell overrides config and is what terminal_tool actually uses. The dump printed only the config value, so a user whose agent was jailed in a docker/podman sandbox via a stale TERMINAL_ENV still saw `terminal: local` — hiding the real cause. Report the effective backend and flag when TERMINAL_ENV overrides config.yaml. --- hermes_cli/dump.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 16d6f6069f..239a6994b6 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -252,9 +252,24 @@ def run_dump(args): except Exception: profile = "(default)" - # Terminal backend + # Terminal backend — report the EFFECTIVE backend, not just config.yaml. + # ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var, + # but a TERMINAL_ENV set directly in .env / the shell overrides config and is + # what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV). + # Reporting only the config value hides that override and sends users chasing + # the wrong cause when the agent runs in a docker/podman sandbox even though + # config says "local" (and vice-versa). run_dump() has already loaded .env, + # so os.environ reflects the real override here. terminal_cfg = config.get("terminal", {}) - backend = terminal_cfg.get("backend", "local") + config_backend = terminal_cfg.get("backend", "local") + env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower() + if env_backend and env_backend != str(config_backend).strip().lower(): + backend = ( + f"{env_backend} (TERMINAL_ENV overrides config.yaml " + f"terminal.backend={config_backend})" + ) + else: + backend = config_backend # OpenAI SDK version try: From 2a08b8c86fc9b94518b9b50d0f6cc0e5834e958b Mon Sep 17 00:00:00 2001 From: xxxigm Date: Mon, 15 Jun 2026 19:09:55 +0700 Subject: [PATCH 60/92] test(dump): cover terminal backend override reporting Verifies `hermes debug` surfaces a TERMINAL_ENV override of terminal.backend, reports the config value when no override is present, and emits no spurious note when env and config agree. --- .../hermes_cli/test_dump_terminal_backend.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/hermes_cli/test_dump_terminal_backend.py diff --git a/tests/hermes_cli/test_dump_terminal_backend.py b/tests/hermes_cli/test_dump_terminal_backend.py new file mode 100644 index 0000000000..46847a8d17 --- /dev/null +++ b/tests/hermes_cli/test_dump_terminal_backend.py @@ -0,0 +1,80 @@ +"""`hermes debug` must report the EFFECTIVE terminal backend. + +``terminal.backend`` in config.yaml is bridged to the ``TERMINAL_ENV`` env var, +but a ``TERMINAL_ENV`` set in .env / the shell overrides config and is what +``terminal_tool`` actually uses. The dump used to print only the config value, +which hid the override and made users believe the agent was running ``local`` +while it was really jailed in a docker/podman sandbox (and vice-versa). +""" + +from pathlib import Path +from types import SimpleNamespace + + +def _terminal_line(out: str) -> str: + for line in out.splitlines(): + if line.startswith("terminal:"): + return line + raise AssertionError(f"no 'terminal:' line in dump output:\n{out}") + + +def _seed(home: Path, *, config_yaml: str, env_text: str) -> None: + home.mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text(config_yaml) + (home / ".env").write_text(env_text) + + +def test_dump_surfaces_terminal_env_override(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + # Keep run_dump's project-.env fallback from touching the real repo. + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + _seed(home, config_yaml="terminal:\n backend: local\n", env_text="TERMINAL_ENV=docker\n") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + # Effective backend (docker) is what actually runs, not the config 'local'. + assert "docker" in line + assert "overrides config.yaml" in line + # The shadowed config value is still shown so the mismatch is obvious. + assert "terminal.backend=local" in line + + +def test_dump_reports_config_backend_when_no_override(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + _seed(home, config_yaml="terminal:\n backend: docker\n", env_text="") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + assert "docker" in line + assert "overrides" not in line + + +def test_dump_no_override_when_env_matches_config(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + # TERMINAL_ENV agrees with config — no spurious "override" note. + _seed(home, config_yaml="terminal:\n backend: docker\n", env_text="TERMINAL_ENV=docker\n") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _terminal_line(capsys.readouterr().out) + assert "docker" in line + assert "overrides" not in line From ed20f5ed060529659687a707d5dbf2fdfd9d6669 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 15:36:51 -0400 Subject: [PATCH 61/92] fix(desktop): let explicit model switches escape broken config providers (#42241) (#46796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a desktop/dashboard session had no agent built yet and the user explicitly picked a provider in the model picker, config.set('model', ...) would first try to initialize the agent from the (possibly broken) config default provider — failing before the user's explicit switch could take effect, trapping them on a misconfigured default. config.set now pre-parses the model flags: if an explicit --provider is present and no agent exists yet, it skips the default-provider agent build and routes straight through _apply_model_switch with the explicit provider. _apply_model_switch gained a parsed_flags passthrough (avoids double-parsing) and only falls back to resolve_runtime_provider(requested=None) when no explicit provider was given. The desktop hook now sends config.set instead of slash.exec for active-session model changes, so errors from the selected provider surface to the user instead of being swallowed. Co-authored-by: rodboev --- .../session/hooks/use-model-controls.test.tsx | 97 ++++++++++++++++++- .../app/session/hooks/use-model-controls.ts | 5 +- tests/test_tui_gateway_server.py | 88 +++++++++++++++++ tui_gateway/server.py | 42 +++++--- 4 files changed, 212 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 8f52018982..612290800e 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -1,5 +1,5 @@ -import { renderHook } from '@testing-library/react' import { QueryClient } from '@tanstack/react-query' +import { cleanup, render, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getGlobalModelInfo } from '@/hermes' @@ -13,12 +13,51 @@ import { import { useModelControls } from './use-model-controls' +const setGlobalModel = vi.fn() +const notifyError = vi.fn() + vi.mock('@/hermes', () => ({ getGlobalModelInfo: vi.fn(), - setGlobalModel: vi.fn() + setGlobalModel: (...args: Parameters) => setGlobalModel(...args) })) -describe('useModelControls.refreshCurrentModel', () => { +vi.mock('@/i18n', () => ({ + useI18n: () => ({ + t: { + desktop: { + modelSwitchFailed: 'Model switch failed' + } + } + }) +})) + +vi.mock('@/store/notifications', () => ({ + notifyError: (...args: Parameters) => notifyError(...args) +})) + +type Controls = ReturnType + +function Harness({ + activeSessionId, + onReady, + requestGateway +}: { + activeSessionId: string | null + onReady: (controls: Controls) => void + requestGateway: (method: string, params?: Record) => Promise +}) { + const controls = useModelControls({ + activeSessionId, + queryClient: new QueryClient(), + requestGateway + }) + + onReady(controls) + + return null +} + +describe('useModelControls', () => { beforeEach(() => { $activeSessionId.set(null) setCurrentModel('') @@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => { }) afterEach(() => { + cleanup() vi.restoreAllMocks() $activeSessionId.set(null) setCurrentModel('') @@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => { expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro') expect($currentProvider.get()).toBe('deepseek') }) + + it('routes active-session picker changes through config.set with an explicit provider', async () => { + const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never) + let controls!: Controls + + render( + (controls = value)} + requestGateway={requestGateway} + /> + ) + + await expect( + controls.selectModel({ + model: 'claude-sonnet-4.6', + persistGlobal: false, + provider: 'anthropic' + }) + ).resolves.toBe(true) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { + session_id: 'session-1', + key: 'model', + value: 'claude-sonnet-4.6 --provider anthropic' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + }) + + it('keeps the global path on setGlobalModel when there is no active session', async () => { + setGlobalModel.mockResolvedValue(undefined) + let controls!: Controls + + render( + (controls = value)} + requestGateway={vi.fn()} + /> + ) + + await expect( + controls.selectModel({ + model: 'claude-sonnet-4.6', + persistGlobal: false, + provider: 'anthropic' + }) + ).resolves.toBe(true) + + expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 525c8d8385..681eac871a 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway try { if (activeSessionId) { - await requestGateway('slash.exec', { + await requestGateway('config.set', { session_id: activeSessionId, - command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` + key: 'model', + value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` }) if (selection.persistGlobal) { diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 90a7f20025..da85cc26ad 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3036,6 +3036,94 @@ def test_config_set_model_global_persists(monkeypatch): assert saved["model"]["base_url"] == "https://api.anthropic.com" +def test_config_set_model_explicit_provider_skips_broken_default_init(monkeypatch): + seen = {"build": 0, "wait": 0, "requested": []} + session = _session() + session["agent"] = None + server._sessions["sid"] = session + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}}) + monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1)) + monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1)) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "_restart_slash_worker", lambda *args, **kwargs: None) + + def fake_runtime_provider(*, requested=None, target_model=None, **_kwargs): + seen["requested"].append((requested, target_model)) + if requested is None: + raise RuntimeError("broken default provider should not be initialized") + if requested == "anthropic": + return { + "api_key": "sk-anthropic", + "api_mode": "anthropic_messages", + "base_url": "https://api.anthropic.com", + } + raise RuntimeError(f"unexpected provider {requested}") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } + ) + + assert resp["result"]["value"] == "claude-sonnet-4-6" + assert seen["build"] == 0 + assert seen["wait"] == 0 + assert seen["requested"] == [("anthropic", "claude-sonnet-4.6")] + assert session["model_override"]["provider"] == "anthropic" + assert session["model_override"]["model"] == "claude-sonnet-4-6" + finally: + server._sessions.pop("sid", None) + + +def test_config_set_model_explicit_provider_surfaces_selected_provider_errors(monkeypatch): + seen = {"build": 0, "wait": 0} + session = _session() + session["agent"] = None + server._sessions["sid"] = session + monkeypatch.setattr(server, "_load_cfg", lambda: {"model": {"default": "broken/model", "provider": "openrouter"}}) + monkeypatch.setattr(server, "_start_agent_build", lambda *_args: seen.__setitem__("build", seen["build"] + 1)) + monkeypatch.setattr(server, "_wait_agent", lambda *_args: seen.__setitem__("wait", seen["wait"] + 1)) + + def fake_runtime_provider(*, requested=None, **_kwargs): + if requested is None: + raise RuntimeError("broken default provider should not be initialized") + if requested == "anthropic": + raise RuntimeError("missing anthropic API key") + raise RuntimeError(f"unexpected provider {requested}") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", fake_runtime_provider) + + try: + resp = server.handle_request( + { + "id": "1", + "method": "config.set", + "params": { + "session_id": "sid", + "key": "model", + "value": "claude-sonnet-4.6 --provider anthropic", + }, + } + ) + + assert resp["error"]["code"] == 5001 + assert "anthropic" in resp["error"]["message"].lower() + assert "missing anthropic api key" in resp["error"]["message"].lower() + assert seen["build"] == 0 + assert seen["wait"] == 0 + finally: + server._sessions.pop("sid", None) + + def test_config_set_model_does_not_leak_inference_provider_env(monkeypatch): """A /model switch must NOT mutate process-global env vars. The desktop / dashboard tui_gateway backend hosts every same-profile session in one diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d34f558f6c..715ca8b48b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1961,11 +1961,14 @@ def _apply_model_switch( *, confirm_expensive_model: bool = False, pin_session_override: bool = True, + parsed_flags: tuple[str, str, bool, bool] | None = None, ) -> dict: from hermes_cli.model_switch import parse_model_flags, switch_model from hermes_cli.runtime_provider import resolve_runtime_provider - model_input, explicit_provider, persist_global, _force_refresh = parse_model_flags(raw_input) + if parsed_flags is None: + parsed_flags = parse_model_flags(raw_input) + model_input, explicit_provider, persist_global, _force_refresh = parsed_flags if not model_input: raise ValueError("model value required") @@ -1976,20 +1979,24 @@ def _apply_model_switch( current_base_url = getattr(agent, "base_url", "") or "" current_api_key = getattr(agent, "api_key", "") or "" else: - runtime = resolve_runtime_provider(requested=None) - current_provider = str(runtime.get("provider", "") or "") current_model = _resolve_model() - current_base_url = str(runtime.get("base_url", "") or "") - # Preserve a callable api_key (Azure Foundry Entra ID bearer - # provider) unchanged — ``str(...)`` would produce - # ``""`` and poison downstream switch_model - # validation. Match the agent-present branch's behavior at the - # top of this block. - _runtime_key = runtime.get("api_key", "") - if callable(_runtime_key) and not isinstance(_runtime_key, str): - current_api_key = _runtime_key - else: - current_api_key = str(_runtime_key or "") + current_provider = explicit_provider.strip() + current_base_url = "" + current_api_key = "" + if not explicit_provider: + runtime = resolve_runtime_provider(requested=None) + current_provider = str(runtime.get("provider", "") or "") + current_base_url = str(runtime.get("base_url", "") or "") + # Preserve a callable api_key (Azure Foundry Entra ID bearer + # provider) unchanged — ``str(...)`` would produce + # ``""`` and poison downstream switch_model + # validation. Match the agent-present branch's behavior at the + # top of this block. + _runtime_key = runtime.get("api_key", "") + if callable(_runtime_key) and not isinstance(_runtime_key, str): + current_api_key = _runtime_key + else: + current_api_key = str(_runtime_key or "") # Load user-defined providers so switch_model can resolve named custom # endpoints (e.g. "ollama-launch") and validate against saved model lists. @@ -6996,7 +7003,11 @@ def _(rid, params: dict) -> dict: 4009, "session busy — /interrupt the current turn before switching models", ) - if session.get("agent") is None: + from hermes_cli.model_switch import parse_model_flags + + parsed_flags = parse_model_flags(value) + _model_input, explicit_provider, _persist_global, _force_refresh = parsed_flags + if session.get("agent") is None and not explicit_provider.strip(): session_id = params.get("session_id", "") _start_agent_build(session_id, session) init_err = _wait_agent(session, rid) @@ -7011,6 +7022,7 @@ def _(rid, params: dict) -> dict: confirm_expensive_model=bool( params.get("confirm_expensive_model", False) ), + parsed_flags=parsed_flags, ) else: result = _apply_model_switch( From 368fcf1ff03b3c6dd562bd590fe92805ecca7461 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 15 Jun 2026 16:16:55 -0400 Subject: [PATCH 62/92] fix(desktop): read HERMES_HOME from the Windows registry when env is stale (#46772) A GUI app launched from Explorer inherits the environment block captured at login, so a HERMES_HOME set via 'setx' AFTER login is invisible in process.env even though the CLI (a fresh shell) sees it. The desktop then silently fell back to %LOCALAPPDATA%\hermes and reported 'No inference provider configured' despite a valid configured home (#45471). resolveHermesHome() now consults the live HKCU\Environment registry value on Windows before the LOCALAPPDATA default. New windows-user-env.cjs helper parses 'reg query' output, expands %VAR% refs, and fails safe (returns null off-Windows, on spawn error, or empty value). The registry value is normalized through the same normalizeHermesHomeRoot() path as the env var for consistency. Co-authored-by: jeffrobodie-glitch --- apps/desktop/electron/main.cjs | 11 +++ apps/desktop/electron/windows-user-env.cjs | 76 ++++++++++++++++ .../electron/windows-user-env.test.cjs | 90 +++++++++++++++++++ apps/desktop/package.json | 2 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/electron/windows-user-env.cjs create mode 100644 apps/desktop/electron/windows-user-env.test.cjs diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c714a46ee4..98b32f0532 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') +const { readWindowsUserEnvVar } = require('./windows-user-env.cjs') const { readDirForIpc } = require('./fs-read-dir.cjs') const { gitRootForIpc } = require('./git-root.cjs') const { worktreesForIpc } = require('./git-worktrees.cjs') @@ -242,6 +243,16 @@ if (INSTALL_STAMP) { function resolveHermesHome() { if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME) if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home') + if (IS_WINDOWS) { + // A GUI app launched from Explorer inherits the environment block captured + // at login, so a HERMES_HOME set via `setx` AFTER login is invisible in + // process.env even though the CLI (a fresh shell) sees it. Without this the + // backend silently falls back to %LOCALAPPDATA%\hermes and reports "No + // inference provider configured" despite a valid configured home (#45471). + // Consult the live User-scoped registry value before the default below. + const fromRegistry = readWindowsUserEnvVar('HERMES_HOME') + if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry) + } if (IS_WINDOWS && process.env.LOCALAPPDATA) { const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes') const legacy = path.join(app.getPath('home'), '.hermes') diff --git a/apps/desktop/electron/windows-user-env.cjs b/apps/desktop/electron/windows-user-env.cjs new file mode 100644 index 0000000000..0ba93d339a --- /dev/null +++ b/apps/desktop/electron/windows-user-env.cjs @@ -0,0 +1,76 @@ +// windows-user-env.cjs +// +// Read a User-scoped environment variable straight from the Windows registry +// (HKCU\Environment). +// +// A GUI app launched from Explorer inherits the environment block captured at +// login, so a variable set via `setx` AFTER login is invisible in process.env +// even though a fresh shell — and the Hermes CLI — sees it immediately. The +// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot +// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading +// the live registry value closes the gap. See #45471. + +const { execFileSync } = require('node:child_process') + +// Parse the output of `reg query HKCU\Environment /v `, which looks like: +// +// HKEY_CURRENT_USER\Environment +// HERMES_HOME REG_SZ F:\Hermes\data +// +// Returns the raw value string (spaces inside the value preserved), or null when +// the requested value line isn't present. +function parseRegQueryValue(stdout, name) { + if (!stdout || !name) return null + const typePattern = + /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + for (const rawLine of String(stdout).split(/\r?\n/)) { + const line = rawLine.trim() + const match = line.match(typePattern) + if (match && match[1].toLowerCase() === name.toLowerCase()) { + return match[2] + } + } + return null +} + +// Expand %VAR% references against an env map. REG_EXPAND_SZ values store +// unexpanded references; plain REG_SZ paths have none, so this is a no-op for +// the common F:\... case. Unknown references are left verbatim. +function expandWindowsEnvRefs(value, env = process.env) { + if (!value) return value + return value.replace(/%([^%]+)%/g, (whole, name) => { + const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + return key != null && env[key] != null ? env[key] : whole + }) +} + +// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null +// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero +// (the value doesn't exist), or when the value is empty. +function readWindowsUserEnvVar( + name, + { platform = process.platform, env = process.env, exec = execFileSync } = {} +) { + if (platform !== 'win32' || !name) return null + let stdout + try { + stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { + encoding: 'utf8', + windowsHide: true, + timeout: 5000 + }) + } catch { + // `reg` missing, or value absent (reg exits 1) — caller falls back. + return null + } + const raw = parseRegQueryValue(stdout, name) + if (raw == null) return null + const expanded = expandWindowsEnvRefs(raw, env).trim() + return expanded || null +} + +module.exports = { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} diff --git a/apps/desktop/electron/windows-user-env.test.cjs b/apps/desktop/electron/windows-user-env.test.cjs new file mode 100644 index 0000000000..dcc71d2c95 --- /dev/null +++ b/apps/desktop/electron/windows-user-env.test.cjs @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { + expandWindowsEnvRefs, + parseRegQueryValue, + readWindowsUserEnvVar +} = require('./windows-user-env.cjs') + +// ── parseRegQueryValue ───────────────────────────────────────────────────── + +test('parseRegQueryValue extracts a REG_SZ value', () => { + const out = [ + '', + 'HKEY_CURRENT_USER\\Environment', + ' HERMES_HOME REG_SZ F:\\Hermes\\data', + '' + ].join('\r\n') + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data') +}) + +test('parseRegQueryValue matches the name case-insensitively', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h') +}) + +test('parseRegQueryValue preserves spaces inside the value', () => { + const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes') +}) + +test('parseRegQueryValue returns null when the value line is absent', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null) +}) + +// ── expandWindowsEnvRefs ─────────────────────────────────────────────────── + +test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => { + assert.equal( + expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), + 'C:\\Users\\jeff\\h' + ) +}) + +test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => { + assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data') + assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x') +}) + +// ── readWindowsUserEnvVar ────────────────────────────────────────────────── + +test('readWindowsUserEnvVar returns null off Windows without spawning', () => { + let spawned = false + const exec = () => { + spawned = true + return '' + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) + assert.equal(spawned, false) +}) + +test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { + const calls = [] + const exec = (cmd, args) => { + calls.push([cmd, args]) + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' + } + const value = readWindowsUserEnvVar('HERMES_HOME', { + platform: 'win32', + env: { DRIVE: 'F:' }, + exec + }) + assert.equal(value, 'F:\\Hermes') + assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) +}) + +test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => { + const exec = () => { + throw new Error('reg exited 1') + } + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) + +test('readWindowsUserEnvVar returns null for an empty value', () => { + const exec = () => ' HERMES_HOME REG_SZ \r\n' + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ebc9293668..08080188a5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -37,7 +37,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-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.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/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.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/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs", "typecheck": "tsc -p . --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From c66ecf0bc30f333eac25113b38eca6b5197e7518 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:33:12 -0700 Subject: [PATCH 63/92] feat(delegation): async background subagents via delegate_task(background=true) (#40946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(delegation): async background subagents via delegate_task(background=true) delegate_task(background=true) dispatches a subagent that runs in the background and returns a handle immediately, so the user and model keep working while it runs. The full result — plus the original task source — re-enters the conversation as a new turn when the subagent finishes, riding the same completion-queue rail as terminal background processes. - tools/async_delegation.py: daemon-executor registry, capacity cap, rich self-contained completion event pushed onto the shared process_registry.completion_queue (type='async_delegation'). - delegate_tool.py: background param + single-task dispatch branch; batch async rejected (v1). - process_registry.py: format_process_notification renders the rich task-source block (goal/context/toolsets/model/status/result). - gateway/run.py: dedicated _async_delegation_watcher drains + injects results into the originating session (idle + post-turn), session_key routing enrichment, shutdown interrupt of dangling delegations. - config: delegation.max_async_children (default 3). Reuses the existing idle-drain wiring rather than mutating a running agent loop, preserving message-role alternation and prompt-cache invariants. 13 targeted tests; CLI + gateway paths E2E-verified. * test(delegation): make async non-blocking tests environment-independent CI 'test (5)' flaked on a cold, 8-worker runner: the first delegate_task(background=true) call measured 2.27s of one-time setup (config load + child-agent construction + imports), tripping the elapsed < 1.0 wall-clock assertion. That assertion was testing setup overhead, not blocking. Replace the wall-clock thresholds with the real invariant: dispatch returns while the child is still gated (active_count == 1, completion queue empty), which a synchronous impl could not do. Keep only a loose 4s sanity backstop well under the runner's 5s gate. * fix(delegation): harden async background delegation Follow-up review fixes: - Detach background child from parent._active_children at dispatch — otherwise parent-turn interrupts (Ctrl+C, mid-turn steering), cache evicts (release_clients), and session close (/new) kill/close the detached subagent mid-run, defeating the point of background mode. Lifecycle is owned by the async registry's interrupt_fn. - Make the capacity check atomic with the record insert (TOCTOU: two concurrent dispatches could both pass active_count() and exceed the cap). - TUI dedup: key async_delegation events by delegation_id — the fallthrough keyed them all as ("", type), suppressing every completion after the first in the desktop/TUI status feed. - CLI /stop now interrupts running background delegations and /agents lists them (they live outside the process registry and were invisible). - Drop stray unbalanced ']' line from the re-injection block and the unused _ASYNC_DEFAULT import. Tests: detach-at-dispatch + concurrent-capacity race added (15 total in test_async_delegation.py); 137 delegate + 140 process-registry/notify/watch + 7 TUI dedup tests pass. * fix(delegation): harden async background completion drains --- cli.py | 5 + gateway/run.py | 136 +++++++- hermes_cli/cli_commands_mixin.py | 40 ++- hermes_cli/config.py | 1 + tests/tools/test_async_delegation.py | 473 +++++++++++++++++++++++++++ tools/async_delegation.py | 386 ++++++++++++++++++++++ tools/delegate_tool.py | 149 +++++++++ tools/process_registry.py | 88 +++++ tui_gateway/server.py | 5 + 9 files changed, 1268 insertions(+), 15 deletions(-) create mode 100644 tests/tools/test_async_delegation.py create mode 100644 tools/async_delegation.py diff --git a/cli.py b/cli.py index ca01b82d5e..bc4f4a76be 100644 --- a/cli.py +++ b/cli.py @@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True): _cleanup_all_terminals() except Exception: pass + try: + from tools.async_delegation import interrupt_all as _interrupt_async_delegations + _interrupt_async_delegations(reason="CLI shutdown") + except Exception: + pass try: _cleanup_all_browsers() except Exception: diff --git a/gateway/run.py b/gateway/run.py index 4541e0fa67..1650851fb7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1921,9 +1921,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None": text += "]" return text + if evt_type == "async_delegation": + # Reuse the shared rich formatter (self-contained task-source block). + from tools.process_registry import format_process_notification + return format_process_notification(evt) + return None +def _drain_gateway_watch_events(completion_queue) -> "list[dict]": + """Drain gateway-owned watch events without spinning on requeued events. + + Watch events are handled by the post-turn gateway drain. Process + completions are owned by their per-process watcher task, and async + delegation completions are owned by ``_async_delegation_watcher``. + Requeueing async events inside ``while not queue.empty()`` would make the + loop non-terminating, so detach the current batch first, then requeue any + events this drain does not own after the queue is empty. + """ + watch_events: list[dict] = [] + requeue: list[dict] = [] + while not completion_queue.empty(): + try: + evt = completion_queue.get_nowait() + except Exception: + break + evt_type = evt.get("type", "completion") + if evt_type in {"watch_match", "watch_disabled"}: + watch_events.append(evt) + elif evt_type == "async_delegation": + requeue.append(evt) + # else: process completion events are handled by the watcher task + for evt in requeue: + completion_queue.put(evt) + return watch_events + + # Module-level weak reference to the active GatewayRunner instance. # Used by tools (e.g. send_message) that need to route through a live # adapter for plugin platforms. Set in GatewayRunner.__init__(). @@ -5353,6 +5386,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # turn so the agent kicks off the new chat. asyncio.create_task(self._handoff_watcher()) + # Start background async-delegation watcher — drains completion events + # from delegate_task(background=true) subagents and injects each + # result back into its originating session as a new turn, covering the + # idle case where the subagent finishes with no agent turn running. + asyncio.create_task(self._async_delegation_watcher()) + logger.info("Press Ctrl+C to stop") return True @@ -5989,6 +6028,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) except Exception as _e: logger.debug("process_registry.kill_all (%s) error: %s", phase, _e) + try: + from tools.async_delegation import interrupt_all as _interrupt_async + _async_n = _interrupt_async(reason=f"gateway shutdown ({phase})") + if _async_n: + logger.info( + "Shutdown (%s): interrupted %d background delegation(s)", + phase, _async_n, + ) + except Exception as _e: + logger.debug("async interrupt_all (%s) error: %s", phase, _e) try: from tools.terminal_tool import cleanup_all_environments cleanup_all_environments() @@ -8995,18 +9044,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew logger.error("Process watcher setup error: %s", e) # Drain watch pattern notifications that arrived during the agent run. - # Watch events and completions share the same queue; completions are - # already handled by the per-process watcher task above, so we only - # inject watch-type events here. + # Watch events and completions share the same queue; process + # completions are already handled by the per-process watcher task + # above, so we only inject watch-type events here. + # + # Async-delegation completions ALSO ride this shared queue but are + # owned by the dedicated _async_delegation_watcher (started at + # boot), which covers both the idle and post-turn cases with a + # single consumer — so we leave them on the queue here. try: from tools.process_registry import process_registry as _pr - _watch_events = [] - while not _pr.completion_queue.empty(): - evt = _pr.completion_queue.get_nowait() - evt_type = evt.get("type", "completion") - if evt_type in {"watch_match", "watch_disabled"}: - _watch_events.append(evt) - # else: completion events are handled by the watcher task + _watch_events = _drain_gateway_watch_events(_pr.completion_queue) for evt in _watch_events: synth_text = _format_gateway_process_notification(evt) if synth_text: @@ -12265,6 +12313,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception as e: logger.error("Watch notification injection error: %s", e) + def _enrich_async_delegation_routing(self, evt: dict) -> None: + """Fill platform/chat_id/thread_id/chat_type on an async-delegation event. + + Async-delegation completion events only carry ``session_key`` (the + daemon worker has no access to the per-message routing metadata the + terminal background watcher captures at spawn time). Parse the + session_key into the routing fields ``_build_process_event_source`` + expects. Best-effort: a CLI-origin event (empty session_key) is left + as-is and simply won't route on the gateway. + """ + if evt.get("platform"): + return # already enriched + parsed = _parse_session_key(evt.get("session_key", "") or "") + if not parsed: + return + evt["platform"] = parsed.get("platform", "") + evt["chat_type"] = parsed.get("chat_type", "") + evt["chat_id"] = parsed.get("chat_id", "") + if parsed.get("thread_id"): + evt["thread_id"] = parsed["thread_id"] + + async def _async_delegation_watcher(self, interval: float = 2.0) -> None: + """Drain async-delegation completions and inject them as new turns. + + Background subagents (``delegate_task(background=true)``) run on the + async-delegation daemon executor — they have no per-process watcher + task, so their completion events would only be seen by the post-turn + queue drain. This watcher covers the IDLE case: when a background + subagent finishes while no agent turn is running, its result still + re-enters the originating session promptly. + + Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the + queue has nothing for us; ignores non-async event types (those are + handled by ``_run_process_watcher`` / the post-turn drain). + """ + await asyncio.sleep(3) # let platforms finish connecting + from tools.process_registry import process_registry as _pr + while self._running: + try: + # Peek the queue for async-delegation events. We must NOT + # consume watch/completion events here (other drains own them), + # so requeue anything that isn't ours. + requeue = [] + async_events = [] + while not _pr.completion_queue.empty(): + try: + evt = _pr.completion_queue.get_nowait() + except Exception: + break + if evt.get("type") == "async_delegation": + async_events.append(evt) + else: + requeue.append(evt) + for evt in requeue: + _pr.completion_queue.put(evt) + for evt in async_events: + self._enrich_async_delegation_routing(evt) + synth_text = _format_gateway_process_notification(evt) + if not synth_text: + continue + try: + await self._inject_watch_notification(synth_text, evt) + except Exception as e: + logger.error("Async delegation injection error: %s", e) + except Exception as e: + logger.debug("Async delegation watcher error: %s", e) + await asyncio.sleep(interval) + async def _run_process_watcher(self, watcher: dict) -> None: """ Periodically check a background process and push updates to the user. diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index b52c6de802..499f8e9a1a 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -225,7 +225,8 @@ class CLICommandsMixin: print(" Usage: /snapshot [list|create [label]|restore |prune [N]]") def _handle_stop_command(self): - """Handle /stop — kill all running background processes. + """Handle /stop — kill all running background processes and + background (async) delegations. Inspired by OpenAI Codex's separation of interrupt (stop current turn) from /stop (clean up background processes). See openai/codex#14602. @@ -235,13 +236,26 @@ class CLICommandsMixin: processes = process_registry.list_sessions() running = [p for p in processes if p.get("status") == "running"] - if not running: + # Background subagents dispatched via delegate_task(background=true) + # live in their own registry, not the process registry. + try: + from tools.async_delegation import active_count, interrupt_all + n_async = active_count() + except Exception: + n_async = 0 + interrupt_all = None + + if not running and not n_async: print(" No running background processes.") return - print(f" Stopping {len(running)} background process(es)...") - killed = process_registry.kill_all() - print(f" ✅ Stopped {killed} process(es).") + if running: + print(f" Stopping {len(running)} background process(es)...") + killed = process_registry.kill_all() + print(f" ✅ Stopped {killed} process(es).") + if n_async and interrupt_all is not None: + stopped = interrupt_all(reason="/stop") + print(f" ✅ Interrupted {stopped} background delegation(s).") def _handle_agents_command(self): """Handle /agents — show background processes and agent status.""" @@ -261,6 +275,22 @@ class CLICommandsMixin: if finished: _cprint(f" Recently finished: {len(finished)}") + # Background (async) delegations — delegate_task(background=true) + try: + from tools.async_delegation import list_async_delegations + delegations = list_async_delegations() + except Exception: + delegations = [] + running_d = [d for d in delegations if d.get("status") == "running"] + if delegations: + _cprint(f" Background delegations: {len(running_d)} running") + for d in delegations: + goal = (d.get("goal") or "")[:60] + _cprint( + f" {d.get('delegation_id', '?')} · " + f"{d.get('status', '?')} · {goal}" + ) + agent_running = getattr(self, "_agent_running", False) _cprint(f" Agent: {'running' if agent_running else 'idle'}") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7ee1f8690c..3a09825620 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1775,6 +1775,7 @@ DEFAULT_CONFIG = { "reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium", # "low", "minimal", "none" (empty = inherit parent's level) "max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling + "max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity # Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth # and _get_orchestrator_enabled). Floored at 1, no upper ceiling — # raise deliberately, each level multiplies API cost. diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py new file mode 100644 index 0000000000..5dbecfc4bf --- /dev/null +++ b/tests/tools/test_async_delegation.py @@ -0,0 +1,473 @@ +"""Tests for async (background) delegation — tools/async_delegation.py. + +Covers the dispatch handle, non-blocking behavior, completion-event delivery +onto the shared process_registry.completion_queue, the rich re-injection block +formatting, capacity rejection, and crash handling. +""" + +import queue +import threading +import time + +import pytest + +from tools import async_delegation as ad +from tools.process_registry import process_registry, format_process_notification + + +@pytest.fixture(autouse=True) +def _clean_state(): + ad._reset_for_tests() + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + yield + ad._reset_for_tests() + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def _drain_one(timeout=5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_registry.completion_queue.empty(): + return process_registry.completion_queue.get_nowait() + time.sleep(0.02) + return None + + +def test_dispatch_returns_immediately_without_blocking(): + gate = threading.Event() + + def runner(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "done", "api_calls": 1, + "duration_seconds": 0.1, "model": "m"} + + t0 = time.monotonic() + res = ad.dispatch_async_delegation( + goal="g", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=runner, max_async_children=3, + ) + elapsed = time.monotonic() - t0 + + assert res["status"] == "dispatched" + assert res["delegation_id"].startswith("deleg_") + # Non-blocking invariant: dispatch returned while the runner is still + # gated (active), so it cannot have waited on the gate. The active_count + # check is the environment-independent proof; the generous wall-clock + # bound is a loose sanity backstop, not the primary assertion (a loaded + # CI runner can be slow but never anywhere near the runner's 5s gate). + assert ad.active_count() == 1 + assert elapsed < 4.0, f"dispatch blocked {elapsed:.2f}s (gate is 5s)" + gate.set() + + +def test_async_executor_workers_are_daemon_threads(): + gate = threading.Event() + + def runner(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "done"} + + res = ad.dispatch_async_delegation( + goal="daemon check", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=runner, max_async_children=1, + ) + assert res["status"] == "dispatched" + + deadline = time.monotonic() + 2 + worker = None + while time.monotonic() < deadline: + worker = next( + (t for t in threading.enumerate() if t.name.startswith("async-delegate")), + None, + ) + if worker is not None: + break + time.sleep(0.02) + assert worker is not None + assert worker.daemon is True + gate.set() + assert _drain_one() is not None + + +def test_completion_event_lands_on_shared_queue_with_session_key(): + def runner(): + return {"status": "completed", "summary": "the result", + "api_calls": 3, "duration_seconds": 2.0, "model": "test-model"} + + res = ad.dispatch_async_delegation( + goal="compute X", context="some context", toolsets=["web", "file"], + role="leaf", model="test-model", session_key="agent:main:cli:dm:local", + runner=runner, max_async_children=3, + ) + assert res["status"] == "dispatched" + + evt = _drain_one() + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["summary"] == "the result" + assert evt["session_key"] == "agent:main:cli:dm:local" + assert evt["delegation_id"] == res["delegation_id"] + + +def test_rich_reinjection_block_is_self_contained(): + def runner(): + return {"status": "completed", "summary": "The answer is 42.", + "api_calls": 7, "duration_seconds": 3.5, "model": "test-model"} + + ad.dispatch_async_delegation( + goal="Compute the meaning of life", + context="User is a philosopher. Respond tersely.", + toolsets=["web"], role="leaf", model="test-model", + session_key="", runner=runner, max_async_children=3, + ) + evt = _drain_one() + assert evt is not None + text = format_process_notification(evt) + assert text is not None + for needle in [ + "ASYNC DELEGATION COMPLETE", + "Compute the meaning of life", + "User is a philosopher", + "Toolsets: web", + "The answer is 42.", + "Status: completed", + "API calls: 7", + ]: + assert needle in text, f"missing {needle!r}" + + +def test_dispatch_rejected_at_capacity(): + ev = threading.Event() + + def blocker(): + ev.wait(timeout=5) + return {"status": "completed", "summary": "x"} + + for i in range(2): + r = ad.dispatch_async_delegation( + goal=f"task{i}", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, max_async_children=2, + ) + assert r["status"] == "dispatched" + + r3 = ad.dispatch_async_delegation( + goal="task3", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=blocker, max_async_children=2, + ) + assert r3["status"] == "rejected" + assert "capacity reached" in r3["error"] + ev.set() + + +def test_crashed_runner_produces_error_completion(): + def boom(): + raise RuntimeError("subagent exploded") + + r = ad.dispatch_async_delegation( + goal="risky", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=boom, max_async_children=3, + ) + assert r["status"] == "dispatched" + evt = _drain_one() + assert evt is not None + assert evt["status"] == "error" + text = format_process_notification(evt) + assert text is not None + assert "did not complete successfully" in text + assert "subagent exploded" in text + + +def test_interrupt_all_signals_running_children(): + ev = threading.Event() + interrupted = {"count": 0} + + def blocker(): + ev.wait(timeout=5) + return {"status": "interrupted", "summary": None, + "error": "cancelled"} + + def interrupt_fn(): + interrupted["count"] += 1 + ev.set() + + ad.dispatch_async_delegation( + goal="long task", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, + interrupt_fn=interrupt_fn, max_async_children=3, + ) + n = ad.interrupt_all(reason="test") + assert n == 1 + assert interrupted["count"] == 1 + # child still emits a completion event after interrupt + evt = _drain_one() + assert evt is not None + assert evt["status"] == "interrupted" + + +def test_completed_records_pruned_to_cap(): + # Run more than the retention cap quickly; ensure list doesn't grow forever. + for i in range(ad._MAX_RETAINED_COMPLETED + 10): + ad.dispatch_async_delegation( + goal=f"t{i}", context=None, toolsets=None, role="leaf", model="m", + session_key="", runner=lambda: {"status": "completed", "summary": "ok"}, + max_async_children=ad._MAX_RETAINED_COMPLETED + 20, + ) + # let workers finish + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and ad.active_count() > 0: + time.sleep(0.05) + assert len(ad.list_async_delegations()) <= ad._MAX_RETAINED_COMPLETED + + +# --------------------------------------------------------------------------- +# Integration: delegate_task(background=True) routing +# --------------------------------------------------------------------------- + +def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch): + """delegate_task(background=True) returns a handle without running the + child synchronously, and the child completes on the background thread.""" + from unittest.mock import MagicMock, patch + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + parent._interrupt_requested = False + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child._subagent_id = "s1" + + gate = threading.Event() + + def slow_child(task_index, goal, child=None, parent_agent=None, **kw): + gate.wait(timeout=5) # a sync impl would hang delegate_task here + return { + "task_index": 0, "status": "completed", "summary": f"done: {goal}", + "api_calls": 1, "duration_seconds": 0.1, "model": "m", + "exit_reason": "completed", + } + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + with patch.object(dt, "_build_child_agent", return_value=fake_child), \ + patch.object(dt, "_run_single_child", side_effect=slow_child), \ + patch.object(dt, "_resolve_delegation_credentials", return_value=creds): + out = dt.delegate_task( + goal="the real task", context="ctx", toolsets=["web"], + background=True, parent_agent=parent, + ) + + import json + parsed = json.loads(out) + assert parsed["status"] == "dispatched" + assert parsed["mode"] == "background" + assert parsed["delegation_id"].startswith("deleg_") + # The real non-blocking invariant (environment-independent — no wall-clock + # threshold that flakes on a loaded CI runner): delegate_task returned + # while the child is STILL blocked on the closed gate, so no completion + # event exists yet. A synchronous impl could not have returned here — it + # would still be inside slow_child waiting on the gate. + assert process_registry.completion_queue.empty() + assert ad.active_count() == 1 # child running in background, not finished + + gate.set() + evt = _drain_one() + assert evt is not None + assert evt["type"] == "async_delegation" + assert evt["summary"] == "done: the real task" + text = format_process_notification(evt) + assert text is not None + assert "the real task" in text and "ctx" in text + + +def test_delegate_task_background_rejects_batch(monkeypatch): + """background=True with a multi-item tasks batch is rejected (v1: single-task only).""" + import json + from unittest.mock import MagicMock + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + + out = dt.delegate_task( + tasks=[{"goal": "a"}, {"goal": "b"}], + background=True, + parent_agent=parent, + ) + parsed = json.loads(out) + assert "error" in parsed + assert "single-task only" in parsed["error"] + + +def test_delegate_task_background_detaches_child_from_parent(monkeypatch): + """A background child must NOT remain in parent._active_children — + otherwise parent-turn interrupts / cache evicts / session close would + kill the detached subagent mid-run.""" + from unittest.mock import MagicMock, patch + import tools.delegate_tool as dt + + parent = MagicMock() + parent._delegate_depth = 0 + parent.session_id = "sess" + parent._active_children = [] + parent._active_children_lock = threading.Lock() + fake_child = MagicMock() + fake_child._delegate_role = "leaf" + fake_child._subagent_id = "s1" + + gate = threading.Event() + + def slow_child(task_index, goal, child=None, parent_agent=None, **kw): + gate.wait(timeout=5) + return {"task_index": 0, "status": "completed", "summary": "ok"} + + def build_and_register(**kw): + # Mirror what the real _build_child_agent does: register the child + # for interrupt propagation. + parent._active_children.append(fake_child) + return fake_child + + creds = { + "model": "m", "provider": None, "base_url": None, "api_key": None, + "api_mode": None, "command": None, "args": None, + } + with patch.object(dt, "_build_child_agent", side_effect=build_and_register), \ + patch.object(dt, "_run_single_child", side_effect=slow_child), \ + patch.object(dt, "_resolve_delegation_credentials", return_value=creds): + out = dt.delegate_task(goal="bg task", background=True, parent_agent=parent) + + import json + assert json.loads(out)["status"] == "dispatched" + # Child detached immediately at dispatch, while it is still running. + assert fake_child not in parent._active_children + gate.set() + assert _drain_one() is not None + + +def test_concurrent_dispatch_respects_capacity(): + """Two threads racing dispatch with cap=1 must yield exactly one accept + (capacity check and record insert are atomic under the records lock).""" + gate = threading.Event() + + def blocker(): + gate.wait(timeout=5) + return {"status": "completed", "summary": "x"} + + results = [] + barrier = threading.Barrier(2) + + def racer(): + barrier.wait(timeout=5) + results.append( + ad.dispatch_async_delegation( + goal="race", context=None, toolsets=None, role="leaf", + model="m", session_key="", runner=blocker, + max_async_children=1, + ) + ) + + threads = [threading.Thread(target=racer) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + statuses = sorted(r["status"] for r in results) + assert statuses == ["dispatched", "rejected"] + gate.set() + + +# --------------------------------------------------------------------------- +# Gateway routing: session_key -> platform/chat_id, rich formatting, injection +# --------------------------------------------------------------------------- + +def _make_async_evt(**over): + evt = { + "type": "async_delegation", + "delegation_id": "deleg_x1", + "session_key": "agent:main:telegram:dm:12345:678", + "goal": "Investigate flaky test", + "context": "repo /tmp/p", + "toolsets": ["terminal"], + "role": "leaf", + "model": "m", + "status": "completed", + "summary": "Found the bug in test_foo", + "api_calls": 4, + "duration_seconds": 12.0, + "dispatched_at": 1000.0, + "completed_at": 1012.0, + } + evt.update(over) + return evt + + +def test_gateway_enriches_routing_from_session_key(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt() + runner._enrich_async_delegation_routing(evt) + assert evt["platform"] == "telegram" + assert evt["chat_id"] == "12345" + assert evt["thread_id"] == "678" + + +def test_gateway_formatter_renders_async_block(): + from gateway.run import _format_gateway_process_notification + + txt = _format_gateway_process_notification(_make_async_evt()) + assert txt is not None + assert "ASYNC DELEGATION COMPLETE" in txt + assert "Found the bug in test_foo" in txt + assert "Investigate flaky test" in txt + + +def test_gateway_watch_drain_requeues_async_without_looping(): + from gateway.run import _drain_gateway_watch_events + + q = queue.Queue() + async_evt = _make_async_evt() + watch_evt = { + "type": "watch_match", + "session_id": "proc_1", + "command": "pytest", + "pattern": "READY", + "output": "READY", + } + q.put(async_evt) + q.put(watch_evt) + + watch_events = _drain_gateway_watch_events(q) + + assert watch_events == [watch_evt] + assert q.qsize() == 1 + assert q.get_nowait() == async_evt + + +def test_gateway_builds_routable_source_from_enriched_event(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt() + runner._enrich_async_delegation_routing(evt) + src = runner._build_process_event_source(evt) + assert src is not None + assert src.platform.value == "telegram" + assert src.chat_id == "12345" + + +def test_gateway_cli_origin_event_left_unrouted(): + """An empty session_key (CLI origin) is left without routing fields.""" + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + evt = _make_async_evt(session_key="") + runner._enrich_async_delegation_routing(evt) + assert "platform" not in evt + + diff --git a/tools/async_delegation.py b/tools/async_delegation.py new file mode 100644 index 0000000000..5975e9b138 --- /dev/null +++ b/tools/async_delegation.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Async (background) delegation registry. + +Backs ``delegate_task(background=true)``: the parent agent dispatches a +subagent that runs on a module-level daemon executor and returns a handle +immediately, so the user and the model can keep working while the child runs. + +When the child finishes, a completion event is pushed onto the SHARED +``process_registry.completion_queue`` with ``type="async_delegation"``. The +CLI (``cli.py`` process_loop) and gateway (``_run_process_watcher`` / +``completion_queue`` drain) already poll that queue while the agent is idle +and forge a fresh user/internal turn from each event. We deliberately reuse +that rail rather than reaching into a running agent loop: + + - completions surface as a NEW turn when the agent is idle, never spliced + between a tool result and an assistant message. That keeps strict + message-role alternation legal and the prompt cache intact (hard + invariant: never mutate past context). + - we inherit the queue's de-dup, crash-recovery checkpoint, and the + existing CLI + gateway drain wiring for free — no new drain loops in the + two largest files in the repo. + +The completion payload carries a RICH, self-contained task-source block (the +original goal, the context the parent supplied, toolsets, model, dispatch +time, status, and the full result summary). When the result re-enters the +conversation the parent may be deep in unrelated context and won't remember +why the subagent existed; the block lets it either use the result or +re-dispatch if the world has moved on. + +This module owns ONLY the async lifecycle. The actual child build + run is +delegated back to ``delegate_tool._run_single_child`` via an injected +runner, so all the credential leasing, heartbeat, timeout, and result-shaping +logic stays in one place. +""" + +from __future__ import annotations + +import logging +import threading +import time +import uuid +import weakref +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures.thread import _worker +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class _DaemonThreadPoolExecutor(ThreadPoolExecutor): + """ThreadPoolExecutor variant whose workers do not block process exit. + + Stdlib ``ThreadPoolExecutor`` workers are non-daemon. Background + delegation is explicitly best-effort detached work, so a long child should + be interruptible by ``/stop``/shutdown but must not keep a CLI process alive + after the user exits. + """ + + def _adjust_thread_count(self) -> None: + if self._idle_semaphore.acquire(timeout=0): + return + + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) + t = threading.Thread( + name=thread_name, + target=_worker, + args=( + weakref.ref(self, weakref_cb), + self._work_queue, + self._initializer, + self._initargs, + ), + daemon=True, + ) + t.start() + self._threads.add(t) + + +# --------------------------------------------------------------------------- +# Module-level state +# --------------------------------------------------------------------------- +# A persistent daemon executor (NOT a `with ThreadPoolExecutor()` block, which +# would join on exit and defeat the whole point of async). Workers are daemon +# threads so a hard process exit doesn't hang on an in-flight child. +_executor: Optional[ThreadPoolExecutor] = None +_executor_lock = threading.Lock() +_executor_max_workers: int = 0 + +_records_lock = threading.Lock() +# delegation_id -> record dict. Kept for the lifetime of the run plus a short +# tail after completion so `list_async_delegations()` can show recent results. +_records: Dict[str, Dict[str, Any]] = {} + +_DEFAULT_MAX_ASYNC_CHILDREN = 3 +# How many completed records to retain for status queries before pruning. +_MAX_RETAINED_COMPLETED = 50 + + +def _get_executor(max_workers: int) -> ThreadPoolExecutor: + """Lazily create (or grow) the shared daemon executor. + + We never shrink — ThreadPoolExecutor can't resize — but if the configured + cap grows between calls we rebuild a larger pool. Existing in-flight + futures keep running on the old pool until it's garbage collected. + """ + global _executor, _executor_max_workers + with _executor_lock: + if _executor is None or max_workers > _executor_max_workers: + # Daemon threads: thread_name_prefix aids debugging in stack dumps. + _executor = _DaemonThreadPoolExecutor( + max_workers=max_workers, + thread_name_prefix="async-delegate", + ) + _executor_max_workers = max_workers + return _executor + + +def active_count() -> int: + """Number of async delegations currently running.""" + with _records_lock: + return sum(1 for r in _records.values() if r.get("status") == "running") + + +def _new_delegation_id() -> str: + return f"deleg_{uuid.uuid4().hex[:8]}" + + +def _prune_completed_locked() -> None: + """Drop the oldest completed records beyond the retention cap. + + Caller must hold ``_records_lock``. + """ + completed = [ + (rid, r) + for rid, r in _records.items() + if r.get("status") != "running" + ] + if len(completed) <= _MAX_RETAINED_COMPLETED: + return + # Oldest-first by completion time (fall back to dispatch time). + completed.sort(key=lambda kv: kv[1].get("completed_at") or kv[1].get("dispatched_at") or 0) + for rid, _ in completed[: len(completed) - _MAX_RETAINED_COMPLETED]: + _records.pop(rid, None) + + +def dispatch_async_delegation( + *, + goal: str, + context: Optional[str], + toolsets: Optional[List[str]], + role: str, + model: Optional[str], + session_key: str, + runner: Callable[[], Dict[str, Any]], + interrupt_fn: Optional[Callable[[], None]] = None, + max_async_children: int = _DEFAULT_MAX_ASYNC_CHILDREN, +) -> Dict[str, Any]: + """Spawn ``runner`` on the daemon executor and return a handle immediately. + + Parameters + ---------- + goal, context, toolsets, role, model + The dispatch-time task spec, captured verbatim for the rich + completion block. + session_key + The gateway session_key (from ``tools.approval.get_current_session_key``) + captured on the parent thread BEFORE dispatch, because the daemon + worker thread won't carry the contextvar. Used to route the + completion back to the originating session. + runner + Zero-arg callable that builds + runs the child and returns the same + result dict ``_run_single_child`` produces. Runs on the worker thread. + interrupt_fn + Optional callable to signal the child to stop (used on shutdown / + explicit cancel). + max_async_children + Concurrency cap. When at capacity the dispatch is REJECTED (the caller + should fall back to sync or tell the user) rather than queued, so a + runaway model can't pile up unbounded background work. + + Returns + ------- + dict + ``{"status": "dispatched", "delegation_id": ...}`` on success, or + ``{"status": "rejected", "error": ...}`` when at capacity. + """ + delegation_id = _new_delegation_id() + dispatched_at = time.time() + record: Dict[str, Any] = { + "delegation_id": delegation_id, + "goal": goal, + "context": context, + "toolsets": list(toolsets) if toolsets else None, + "role": role, + "model": model, + "session_key": session_key, + "status": "running", + "dispatched_at": dispatched_at, + "completed_at": None, + "interrupt_fn": interrupt_fn, + } + # Capacity check and record insert under ONE lock hold — checking + # active_count() separately would let two concurrent dispatches (e.g. + # from different gateway sessions) both pass the check and exceed the cap. + with _records_lock: + running = sum( + 1 for r in _records.values() if r.get("status") == "running" + ) + if running >= max_async_children: + return { + "status": "rejected", + "error": ( + f"Async delegation capacity reached ({max_async_children} " + f"running). Wait for one to finish (its result will re-enter " + f"the chat), or run this task synchronously " + f"(background=false). Raise delegation.max_async_children in " + f"config.yaml to allow more concurrent background subagents." + ), + } + _records[delegation_id] = record + + executor = _get_executor(max_async_children) + + def _worker() -> None: + result: Dict[str, Any] = {} + status = "error" + try: + result = runner() or {} + status = result.get("status") or "completed" + except Exception as exc: # noqa: BLE001 — must never crash the worker + logger.exception("Async delegation %s crashed", delegation_id) + result = { + "status": "error", + "summary": None, + "error": f"{type(exc).__name__}: {exc}", + "api_calls": 0, + "duration_seconds": round(time.time() - dispatched_at, 2), + } + status = "error" + finally: + _finalize(delegation_id, result, status) + + try: + executor.submit(_worker) + except Exception as exc: # pragma: no cover — pool submit failure is rare + with _records_lock: + _records.pop(delegation_id, None) + return { + "status": "rejected", + "error": f"Failed to schedule async delegation: {exc}", + } + + logger.info( + "Dispatched async delegation %s (session_key=%s): %s", + delegation_id, session_key or "", (goal or "")[:80], + ) + return {"status": "dispatched", "delegation_id": delegation_id} + + +def _finalize(delegation_id: str, result: Dict[str, Any], status: str) -> None: + """Mark a record complete and push the completion event onto the queue.""" + with _records_lock: + record = _records.get(delegation_id) + if record is None: + return + record["status"] = status + record["completed_at"] = time.time() + record["interrupt_fn"] = None # drop the closure; child is done + # Snapshot fields needed for the event while holding the lock. + event_record = dict(record) + _prune_completed_locked() + + _push_completion_event(event_record, result, status) + + +def _push_completion_event( + record: Dict[str, Any], result: Dict[str, Any], status: str +) -> None: + """Push a type='async_delegation' event onto the shared completion queue. + + Best-effort: a failure here must not crash the worker, but it WOULD mean a + silently-lost result, so we log loudly. + """ + try: + from tools.process_registry import process_registry + except Exception as exc: # pragma: no cover + logger.error( + "Async delegation %s finished but process_registry import failed; " + "result lost: %s", + record.get("delegation_id"), exc, + ) + return + + summary = result.get("summary") + error = result.get("error") + dispatched_at = record.get("dispatched_at") or time.time() + completed_at = record.get("completed_at") or time.time() + + evt = { + "type": "async_delegation", + "delegation_id": record.get("delegation_id"), + # session_key routes the completion back to the originating gateway + # session; empty string => CLI (single-session) path. + "session_key": record.get("session_key", ""), + "goal": record.get("goal", ""), + "context": record.get("context"), + "toolsets": record.get("toolsets"), + "role": record.get("role"), + "model": result.get("model") or record.get("model"), + "status": status, + "summary": summary, + "error": error, + "api_calls": result.get("api_calls", 0), + "duration_seconds": result.get( + "duration_seconds", round(completed_at - dispatched_at, 2) + ), + "dispatched_at": dispatched_at, + "completed_at": completed_at, + "exit_reason": result.get("exit_reason"), + } + try: + process_registry.completion_queue.put(evt) + except Exception as exc: # pragma: no cover + logger.error( + "Async delegation %s: failed to enqueue completion event; " + "result lost: %s", + record.get("delegation_id"), exc, + ) + + +def list_async_delegations() -> List[Dict[str, Any]]: + """Snapshot of async delegations (running + recently completed). + + Safe to call from any thread. Excludes the non-serialisable interrupt_fn. + """ + with _records_lock: + return [ + {k: v for k, v in r.items() if k != "interrupt_fn"} + for r in _records.values() + ] + + +def interrupt_all(reason: str = "shutdown") -> int: + """Signal every running async delegation to stop. Returns how many. + + Used on ``/stop`` and gateway shutdown so a dangling background subagent + can't keep burning tokens with no one listening. The child still emits a + completion event (status='interrupted') via the normal finalize path. + """ + count = 0 + with _records_lock: + targets = [ + r for r in _records.values() if r.get("status") == "running" + ] + for r in targets: + fn = r.get("interrupt_fn") + if callable(fn): + try: + fn() + count += 1 + except Exception as exc: + logger.debug( + "interrupt_all: %s interrupt failed: %s", + r.get("delegation_id"), exc, + ) + if count: + logger.info("Interrupted %d async delegation(s) (%s)", count, reason) + return count + + +def _reset_for_tests() -> None: + """Test-only: clear all state and tear down the executor.""" + global _executor, _executor_max_workers + with _executor_lock: + if _executor is not None: + _executor.shutdown(wait=False) + _executor = None + _executor_max_workers = 0 + with _records_lock: + _records.clear() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index fb17c537b9..7fc82c72fe 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -397,6 +397,38 @@ def _get_max_concurrent_children() -> int: return _DEFAULT_MAX_CONCURRENT_CHILDREN +_DEFAULT_MAX_ASYNC_CHILDREN = 3 + + +def _get_max_async_children() -> int: + """Read delegation.max_async_children from config (floor 1, no ceiling). + + Caps how many background (``background=true``) subagents can run at once. + When at capacity, a new async dispatch is REJECTED (not queued) so a + runaway model can't pile up unbounded background work. Separate from + max_concurrent_children, which bounds a single synchronous batch. + """ + cfg = _load_config() + val = cfg.get("max_async_children") + if val is not None: + try: + return max(1, int(val)) + except (TypeError, ValueError): + logger.warning( + "delegation.max_async_children=%r is not a valid integer; " + "using default %d", + val, _DEFAULT_MAX_ASYNC_CHILDREN, + ) + return _DEFAULT_MAX_ASYNC_CHILDREN + env_val = os.getenv("DELEGATION_MAX_ASYNC_CHILDREN") + if env_val: + try: + return max(1, int(env_val)) + except (TypeError, ValueError): + return _DEFAULT_MAX_ASYNC_CHILDREN + return _DEFAULT_MAX_ASYNC_CHILDREN + + def _get_child_timeout() -> Optional[float]: """Read delegation.child_timeout_seconds from config. @@ -2018,6 +2050,7 @@ def delegate_task( acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, role: Optional[str] = None, + background: Optional[bool] = None, parent_agent=None, ) -> str: """ @@ -2049,6 +2082,19 @@ def delegate_task( # Normalise the top-level role once; per-task overrides re-normalise. top_role = _normalize_role(role) + # Async (background) delegation is single-task only in v1. A batch carries + # fan-out semantics (N handles, partial completion) that double the state + # model — reject early with a clear message rather than silently running + # the batch synchronously. + background = is_truthy_value(background, default=False) if background is not None else False + if background and tasks and isinstance(tasks, list) and len(tasks) > 1: + return tool_error( + "background=true is single-task only. Dispatch one background " + "subagent per delegate_task call (each returns its own handle and " + "re-enters the conversation independently), or run the batch " + "synchronously with background=false." + ) + # Depth limit — configurable via delegation.max_spawn_depth, # default 2 for parity with the original MAX_DEPTH constant. depth = getattr(parent_agent, "_delegate_depth", 0) @@ -2186,6 +2232,90 @@ def delegate_task( if n_tasks == 1: # Single task -- run directly (no thread pool overhead) _i, _t, child = children[0] + + # ----- Async / background dispatch ----- + # When background=true, hand the already-built child to the async + # delegation registry and return a handle immediately. The child runs + # on a daemon executor; its result re-enters the conversation as a + # fresh turn via process_registry.completion_queue (see + # tools/async_delegation.py). Batch async is intentionally NOT + # supported in v1 — the rejection is handled before we get here. + if background: + from tools.async_delegation import dispatch_async_delegation + from tools.approval import get_current_session_key + + # Capture the gateway routing key on THIS (parent) thread — the + # daemon worker won't carry the session contextvar. + _session_key = get_current_session_key(default="") + + # Detach the child from the parent's interrupt-propagation list. + # _build_child_agent registered it there (correct for sync + # children, which block the parent's turn), but a BACKGROUND + # child must survive parent-turn interrupts (Ctrl+C, mid-turn + # steering), cache evicts (release_clients), and session close + # (/new) — otherwise the detached subagent dies with whatever + # the parent was doing when it was dispatched. Its lifecycle is + # owned by the async-delegation registry (interrupt_fn below), + # and _run_single_child's finally block closes its resources + # when it finishes. + if hasattr(parent_agent, "_active_children"): + try: + _ac_lock = getattr(parent_agent, "_active_children_lock", None) + if _ac_lock: + with _ac_lock: + parent_agent._active_children.remove(child) + else: + parent_agent._active_children.remove(child) + except ValueError: + pass + + def _async_runner(_child=child, _goal=_t["goal"]): + return _run_single_child(0, _goal, _child, parent_agent) + + def _async_interrupt(_child=child): + try: + if hasattr(_child, "interrupt"): + _child.interrupt("Async delegation cancelled") + elif hasattr(_child, "_interrupt_requested"): + _child._interrupt_requested = True + except Exception: + pass + + dispatch = dispatch_async_delegation( + goal=_t["goal"], + context=_t.get("context"), + toolsets=_t.get("toolsets") or toolsets, + role=_normalize_role(_t.get("role") or top_role), + model=creds["model"], + session_key=_session_key, + runner=_async_runner, + interrupt_fn=_async_interrupt, + max_async_children=_get_max_async_children(), + ) + + if dispatch.get("status") == "dispatched": + return json.dumps( + { + "status": "dispatched", + "delegation_id": dispatch["delegation_id"], + "goal": _t["goal"], + "mode": "background", + "note": ( + "Subagent is running in the background. You and the " + "user can keep working; the full task source and " + "result will re-enter the conversation as a new " + "message when it finishes. Do not wait or poll — " + "just continue." + ), + }, + ensure_ascii=False, + ) + # Rejected (at capacity or schedule failure) — surface as a tool + # error so the model can fall back to synchronous delegation. + return tool_error( + dispatch.get("error", "Async delegation could not be scheduled.") + ) + result = _run_single_child(0, _t["goal"], child, parent_agent) results.append(result) else: @@ -2904,6 +3034,24 @@ DELEGATE_TASK_SCHEMA = { "enum": ["leaf", "orchestrator"], "description": "(rebuilt at get_definitions() time)", }, + "background": { + "type": "boolean", + "description": ( + "Run the subagent asynchronously in the BACKGROUND " + "instead of blocking this turn. When true, delegate_task " + "returns immediately with a delegation_id; you and the " + "user keep working while the subagent runs, and its full " + "result re-enters the conversation as a new message when " + "it finishes (similar to terminal background=true + " + "notify_on_complete). The re-injected message includes the " + "original goal/context so you can act on it even after " + "moving on. Single-task only — cannot be combined with the " + "'tasks' batch array. Use for long-running independent work " + "the user shouldn't have to wait on (research, builds, " + "multi-step investigations). Do NOT poll or wait after " + "dispatching — just continue; the result will come to you." + ), + }, "acp_command": { "type": "string", "description": ( @@ -2948,6 +3096,7 @@ registry.register( acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), role=args.get("role"), + background=args.get("background"), parent_agent=kw.get("parent_agent"), ), check_fn=check_delegate_requirements, diff --git a/tools/process_registry.py b/tools/process_registry.py index 6c3d61ce5f..e9f3276ffb 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1531,6 +1531,91 @@ class ProcessRegistry: process_registry = ProcessRegistry() +def _format_age(seconds: float) -> str: + """Human-friendly elapsed string ('18m', '2h3m', '45s').""" + try: + s = int(max(0, seconds)) + except (TypeError, ValueError): + return "?" + if s < 60: + return f"{s}s" + m, s = divmod(s, 60) + if m < 60: + return f"{m}m" if s == 0 else f"{m}m{s}s" + h, m = divmod(m, 60) + return f"{h}h" if m == 0 else f"{h}h{m}m" + + +def _format_async_delegation(evt: dict) -> str: + """Format an async-delegation completion into a self-contained re-injection. + + Carries the FULL original task source (goal, the context the parent + supplied, toolsets, role, model) plus dispatch time, status, and the + complete result summary. When this re-enters the conversation the agent + may be deep in unrelated context and won't remember why the subagent + existed, so the block is written to stand entirely on its own — enough to + use the result OR re-dispatch if the world has moved on. + """ + import time as _time + + deleg_id = evt.get("delegation_id", "unknown") + goal = evt.get("goal", "") or "" + context = evt.get("context") + toolsets = evt.get("toolsets") + role = evt.get("role") or "leaf" + model = evt.get("model") or "?" + status = evt.get("status") or "completed" + summary = evt.get("summary") + error = evt.get("error") + api_calls = evt.get("api_calls", 0) + duration = evt.get("duration_seconds", "?") + dispatched_at = evt.get("dispatched_at") + completed_at = evt.get("completed_at") or _time.time() + + age = "" + if isinstance(dispatched_at, (int, float)): + age = f" ({_format_age(completed_at - dispatched_at)} ago)" + + lines = [ + f"[ASYNC DELEGATION COMPLETE — {deleg_id}]", + "A background subagent you dispatched earlier has finished. You may " + "have moved on since dispatching it; the full task source is below so " + "you can act on the result or re-dispatch if things have changed.", + "", + ] + if isinstance(dispatched_at, (int, float)): + ts = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(dispatched_at)) + lines.append(f"Dispatched: {ts}{age}") + lines.append(f"Original goal: {goal}") + if context: + lines.append(f"Context you provided: {context}") + if toolsets: + lines.append(f"Toolsets: {', '.join(toolsets)}") + lines.append(f"Role: {role} Model: {model}") + lines.append(f"Status: {status} API calls: {api_calls} Duration: {duration}s") + lines.append("--- RESULT ---") + if status in ("completed", "success") and summary: + lines.append(summary) + elif status == "interrupted": + lines.append( + "The subagent was interrupted before completing" + + (f": {error}" if error else ".") + ) + if summary: + lines.append("Partial output:") + lines.append(summary) + else: + # error / timeout / failed + lines.append( + f"The subagent did not complete successfully (status={status})." + + (f"\n{error}" if error else "") + ) + if summary: + lines.append("Partial output:") + lines.append(summary) + return "\n".join(lines) + + def format_process_notification(evt: dict) -> "str | None": """Format a process notification event into a [IMPORTANT: ...] message. @@ -1559,6 +1644,9 @@ def format_process_notification(evt: dict) -> "str | None": text += "]" return text + if evt_type == "async_delegation": + return _format_async_delegation(evt) + _exit = evt.get("exit_code", "?") _out = evt.get("output", "") _reason = evt.get("completion_reason") or "exited" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 715ca8b48b..4d12a1a417 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5595,6 +5595,11 @@ def _notification_event_dedup_key(evt: dict) -> tuple: evt.get("message", ""), evt.get("suppressed", 0), ) + if evt_type == "async_delegation": + # Async-delegation completions have no process session_id; without + # this the fallthrough keys every one as ("", "async_delegation") + # and the second completion's status update is suppressed forever. + return (evt.get("delegation_id", ""), evt_type) return (evt_sid, evt_type) From e0492aa2dca01e62280f8de0870af62f0d404d9e Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 15 Jun 2026 17:03:44 -0400 Subject: [PATCH 64/92] fix(ci): always run pull_request checks no waiting for pending forever! --- .github/workflows/contributor-check.yml | 5 ++--- .github/workflows/docker-lint.yml | 9 ++++---- .github/workflows/docker-publish.yml | 13 +++++------- .github/workflows/docs-site-checks.yml | 16 ++++++++------- .github/workflows/history-check.yml | 7 +++++-- .github/workflows/lint.yml | 9 ++++---- .github/workflows/osv-scanner.yml | 26 +++++++++--------------- .github/workflows/supply-chain-audit.yml | 12 +++++------ .github/workflows/tests.yml | 8 ++++---- .github/workflows/typecheck.yml | 3 +++ .github/workflows/uv-lockfile-check.yml | 18 ++++++++-------- 11 files changed, 61 insertions(+), 65 deletions(-) diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index de38fcaae9..23266931a6 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -1,12 +1,11 @@ name: Contributor Attribution Check on: - pull_request: - branches: [main] # No paths filter — the job must always run so the required check # reports a status (path-gated workflows leave checks "pending" forever # when no matching files change, which blocks merge). - + pull_request: + branches: [main] permissions: contents: read diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index f1673813e9..631add200a 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -18,13 +18,12 @@ on: - docker/** - .hadolint.yaml - .github/workflows/docker-lint.yml + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - Dockerfile - - docker/** - - .hadolint.yaml - - .github/workflows/docker-lint.yml permissions: contents: read diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c12ad772fa..09b8913841 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,16 +11,13 @@ on: - 'docker/**' - '.github/workflows/docker-publish.yml' - '.github/actions/hermes-smoke-test/**' + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' - - 'Dockerfile' - - 'docker/**' - - '.github/workflows/docker-publish.yml' - - '.github/actions/hermes-smoke-test/**' + release: types: [published] diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 7001c0b743..975028afe2 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -1,10 +1,12 @@ name: Docs Site Checks on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: - paths: - - 'website/**' - - '.github/workflows/docs-site-checks.yml' + branches: [main] + workflow_dispatch: permissions: @@ -14,9 +16,9 @@ jobs: docs-site-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -26,9 +28,9 @@ jobs: run: npm ci working-directory: website - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.11' + python-version: "3.11" - name: Install ascii-guard run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3 diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index 46f5368f79..ef657d5982 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -14,6 +14,9 @@ name: History Check # the PR head and main to be non-empty. on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] @@ -24,9 +27,9 @@ jobs: check-common-ancestor: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 # full history both sides for merge-base + fetch-depth: 0 # full history both sides for merge-base - name: Reject PRs with no common ancestor on main run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 013d212020..f2765823a0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -15,12 +15,12 @@ on: - "**/*.md" - "docs/**" - "website/**" + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" - - "website/**" permissions: contents: read @@ -154,7 +154,6 @@ jobs: }); } - ruff-blocking: # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently # PLW1514 (unspecified-encoding) — catches bare ``open()`` / diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index c7d4b5bb06..d1b318cc73 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -20,29 +20,23 @@ name: OSV-Scanner # vulnerabilities in pinned deps that we may need to patch deliberately. on: + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'ui-tui/package.json' - - 'website/package.json' - - 'website/package-lock.json' - - '.github/workflows/osv-scanner.yml' push: branches: [main] paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'website/package-lock.json' + - "uv.lock" + - "pyproject.toml" + - "package.json" + - "package-lock.json" + - "website/package-lock.json" schedule: # Weekly scan against main — catches CVEs published after merge for # deps that haven't changed since. - - cron: '0 9 * * 1' + - cron: "0 9 * * 1" workflow_dispatch: permissions: @@ -54,7 +48,7 @@ permissions: jobs: scan: name: Scan lockfiles - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: # Scan explicit lockfiles rather than recursing, so we only look at # the three sources of truth and skip vendored / test / worktree dirs. diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 4bee46a95c..f3405b7660 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -1,11 +1,11 @@ name: Supply Chain Audit on: - pull_request: - types: [opened, synchronize, reopened] # No paths filter — the jobs must always run so required checks # report a status (path-gated workflows leave checks "pending" forever # when no matching files change, which blocks merge). + pull_request: + types: [opened, synchronize, reopened] permissions: pull-requests: write @@ -32,7 +32,7 @@ jobs: # True when the curated MCP catalog / bundled MCP manifests changed. mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Check for relevant file changes @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -207,7 +207,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -286,7 +286,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6e7738fa4..c1f59c5094 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,11 +6,11 @@ on: paths-ignore: - "**/*.md" - "docs/**" + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" permissions: contents: read @@ -219,4 +219,4 @@ jobs: env: OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" - NOUS_API_KEY: "" \ No newline at end of file + NOUS_API_KEY: "" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e21b80864c..29994e3e29 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,6 +4,9 @@ name: Typecheck on: push: branches: [main] + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 37c31799be..54662b23ed 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -47,15 +47,15 @@ on: push: branches: [main] paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/uv-lockfile-check.yml" + + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). pull_request: branches: [main] - paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' permissions: contents: read @@ -71,10 +71,10 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 # `uv lock --check` re-resolves the project from pyproject.toml and # compares the result to uv.lock, exiting non-zero if they disagree. From c7513df4f9e4af2d33a73a3a5256e2d4f346ee26 Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:34:09 +0000 Subject: [PATCH 65/92] docs(honcho): clarify pinUserPeer pins only non-agent users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'everyone collapses to your peer' read as a promise about all traffic. pinUserPeer pins the user-side peer and is checked before userPeerAliases (session.py:335), so a pin overrides every alias — including agent peers. For a multi-agent operator that silently pools distinct agents onto one peer, the opposite of intent. Scopes the wording to 'every non-agent gateway user', notes the pin overrides aliases, and points agent-mesh operators at pinUserPeer:false + userPeerAliases instead. Same correction in the wizard menu/echo text, the plugin README, and the website Honcho page. --- plugins/memory/honcho/README.md | 2 +- plugins/memory/honcho/cli.py | 4 ++-- website/docs/user-guide/features/honcho.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 70fe1fb531..cb9b720bf5 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -161,7 +161,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a **Setup — gateway identity tree.** `hermes honcho setup` only asks about identity mapping when it detects a connected gateway platform (it inspects the gateway config; off-gateway the step is skipped because these keys do nothing without a runtime user ID). When it runs, it asks *who talks to this gateway?* and derives the keys: -- **just me** → `pinUserPeer: true`. All gateway users collapse to `peerName`. Personal use where you connect Hermes to your own Telegram/Discord/etc. +- **just me** → `pinUserPeer: true`. Every non-agent gateway user collapses to `peerName`; the pin overrides all aliases, so pick this only when no user-side identity needs its own peer. Personal use where you connect Hermes to your own Telegram/Discord/etc. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor). - **me + other people, pooled** → `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName`. You stay on the shared history; everyone else gets their own peer. - **me + other people / only other people** → `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. For bots serving many humans. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 25460989df..cc19711e95 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -699,7 +699,7 @@ def cmd_setup(args) -> None: peer_target = hermes_host.get("peerName") or current_peer or "user" default_choice = {"single": "1", "hybrid": "2", "multi": "3"}.get(current_shape, "3") print("\n How should gateway users map to memory peers?") - print(" [1] just me — everyone collapses to your peer") + print(" [1] just me — every non-agent user collapses to your peer") print(" [2] me + other people — keep mine pooled, others separate") print(" [3] only other people — everyone gets their own peer") print(" [s] skip (leave untouched) [e] edit raw keys") @@ -739,7 +739,7 @@ def cmd_setup(args) -> None: if shape == "single": _scrub_identity_mapping(hermes_host) hermes_host["pinUserPeer"] = True - print(f" All gateway users route to '{peer_target}'.") + print(f" All non-agent gateway users route to '{peer_target}' (pin overrides aliases).") _echo_identity_mapping(hermes_host) elif shape == "multi": # Preserve operator-curated host-level aliases across multi → multi diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index a692b26d96..31d8391383 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -165,7 +165,7 @@ The setup wizard detects whether a gateway platform is connected and skips this | Answer | Result | |--------|--------| -| **just me** | `pinUserPeer: true` — everyone collapses to your peer | +| **just me** | `pinUserPeer: true` — every non-agent gateway user collapses to your peer. Pin overrides all aliases, so pick this only when no user-side identity needs its own peer. If separate agents reach the gateway and each needs a distinct peer, do **not** pin — leave `pinUserPeer: false` and map them via `userPeerAliases` (the `[e]` editor) instead | | **me + other people** (pooled) | `pinUserPeer: false` + `userPeerAliases` mapping your runtime IDs to `peerName` — you stay on your shared history, others get their own peers | | **only other people** | `pinUserPeer: false`, optional `runtimePeerPrefix` — each user gets their own peer | From 6dde7d46574f7bd40e915217d96074c461791c4f Mon Sep 17 00:00:00 2001 From: Erosika Date: Mon, 15 Jun 2026 21:50:24 +0000 Subject: [PATCH 66/92] docs(memory-providers): cover gateway identity mapping for Honcho MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Honcho provider page documented the per-profile peer model (user peer / AI peer / observation) but never the gateway axis — how platform runtime IDs map to peers. Adds the three keys to the config table and a short Gateway identity mapping subsection that points at the Honcho page for the resolver ladder. Uses the corrected pinUserPeer wording (pins non-agent users, overrides aliases) so the provider-comparison reader gets the same accurate framing as the dedicated page. --- .../docs/user-guide/features/memory-providers.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 43b70334da..476bd46696 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -95,6 +95,9 @@ The legacy `hermes honcho setup` command still works (it now redirects to `herme | `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) | | `dialecticMaxInputChars` | `10000` | Max chars for dialectic query input to `peer.chat()` | | `sessionStrategy` | `'per-directory'` | `per-directory`, `per-repo`, `per-session`, `global` | +| `pinUserPeer` | `false` | Gateway only. When `true`, every non-agent gateway user collapses to `peerName`; the pin overrides all aliases | +| `userPeerAliases` | `{}` | Gateway only. Maps runtime IDs to peers (`{"7654321": "alice"}`). Many-to-one | +| `runtimePeerPrefix` | `""` | Gateway only. Namespaces unknown runtime IDs (`telegram_7654321`) when no alias matches | @@ -199,6 +202,18 @@ Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win o See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference. +### Gateway identity mapping + +The peer model above covers CLI, TUI, and desktop sessions, where every conversation resolves to `peerName`. The [gateway](../../developer-guide/gateway-internals.md) adds a second axis: users arrive with platform-native runtime IDs (Telegram UID, Discord snowflake, Slack user), and three keys decide which peer each ID resolves to. + +| Key | Effect | +|-----|--------| +| `pinUserPeer: true` | Every non-agent gateway user collapses to `peerName`. The pin is checked first, so it overrides all aliases — pick it only when no user-side identity needs its own peer | +| `userPeerAliases` | Maps specific runtime IDs to peers (`{"7654321": "alice"}`). The home for routing distinct identities — including agents that each carry their own peer | +| `runtimePeerPrefix` | Namespaces any unmapped runtime ID (`telegram_7654321`) so platforms with same-shaped IDs don't collide | + +Off-gateway these keys do nothing. `hermes memory setup` only prompts for them when it detects a connected gateway platform. See the [Honcho page](./honcho.md#gateway-identity-mapping) for the resolver ladder and the setup flow. +
Full honcho.json example (multi-profile) From 5a0e0d35b94fefae4ff6463c24f53e348f4679e6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:52:13 -0700 Subject: [PATCH 67/92] fix(mattermost): preserve thread-local delivery hygiene Salvage the valid thread-routing pieces from #41640: - route Mattermost progress/status sends through metadata thread IDs - treat top-level Mattermost channel posts as thread roots for progress - preserve thread metadata through media/file sends - allow flat fallback only for final notify-worthy replies on confirmed broken roots Co-authored-by: Wolfram Ravenwolf --- gateway/run.py | 18 ++- plugins/platforms/mattermost/adapter.py | 124 +++++++++++++---- tests/gateway/test_mattermost.py | 172 ++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 28 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 1650851fb7..1c29a593e3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -402,6 +402,17 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met return await adapter.send(chat_id, content, metadata=metadata) +def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]: + """Return thread/root ID that progress/status bubbles should target.""" + platform_value = getattr(platform, "value", platform) + platform_key = str(platform_value or "").lower() + if source_thread_id: + return str(source_thread_id) + if platform_key in {"slack", "mattermost"} and event_message_id: + return str(event_message_id) + return None + + def _telegramize_command_mentions(text: str, platform: Any) -> str: """Rewrite slash-command mentions to Telegram-valid command names. @@ -13884,10 +13895,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # - Feishu only honors reply_in_thread when sending a reply, so topic # progress uses the triggering event message as the reply target # - Other platforms should use explicit source.thread_id only - if source.platform == Platform.SLACK: - _progress_thread_id = source.thread_id or event_message_id - else: - _progress_thread_id = source.thread_id + _progress_thread_id = _resolve_progress_thread_id( + source.platform, source.thread_id, event_message_id, + ) _progress_metadata = ( self._thread_metadata_for_source(source, event_message_id) if _progress_thread_id == source.thread_id diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index bb6dc9b81f..bc2280cb6d 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -96,6 +96,9 @@ class MattermostAdapter(BasePlatformAdapter): or os.getenv("MATTERMOST_REPLY_MODE", "off") ).lower() + self._last_post_status: Optional[int] = None + self._last_post_error: str = "" + # Dedup cache (prevent reprocessing) self._dedup = MessageDeduplicator() @@ -130,20 +133,79 @@ class MattermostAdapter(BasePlatformAdapter): """POST /api/v4/{path} with JSON body.""" import aiohttp url = f"{self._base_url}/api/v4/{path.lstrip('/')}" + self._last_post_status = None + self._last_post_error = "" try: async with self._session.post( url, headers=self._headers(), json=payload, timeout=aiohttp.ClientTimeout(total=30) ) as resp: + self._last_post_status = resp.status if resp.status >= 400: body = await resp.text() + self._last_post_error = body or "" logger.error("MM API POST %s → %s: %s", path, resp.status, body[:200]) return {} return await resp.json() except aiohttp.ClientError as exc: + self._last_post_error = str(exc) logger.error("MM API POST %s network error: %s", path, exc) return {} + async def _thread_root_for_send( + self, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Optional[str]: + """Resolve the Mattermost root_id from reply_to or metadata.""" + if self._reply_mode != "thread": + return None + candidate = reply_to + if not candidate and isinstance(metadata, dict): + candidate = metadata.get("thread_id") or metadata.get("root_id") + if not candidate: + return None + return await self._resolve_root_id(str(candidate)) + + def _last_post_failure_is_broken_thread_root(self) -> bool: + """Return True only for clear invalid/missing Mattermost thread roots.""" + if self._last_post_status not in {400, 404}: + return False + body = (self._last_post_error or "").lower() + if not body: + return False + rootish = any(marker in body for marker in ("root_id", "rootid", "root id", "thread", "post")) + broken = any(marker in body for marker in ("invalid", "not found", "does not exist", "missing")) + return rootish and broken + + async def _post_preserving_thread( + self, + chat_id: str, + payload: Dict[str, Any], + metadata: Optional[Dict[str, Any]], + ) -> Dict[str, Any]: + """Post once, optionally falling back flat for final notify content.""" + data = await self._api_post("posts", payload) + if data or "root_id" not in payload: + return data + if not (isinstance(metadata, dict) and metadata.get("notify")): + return data + if not self._last_post_failure_is_broken_thread_root(): + return data + + flat_payload = dict(payload) + flat_payload.pop("root_id", None) + original = str(flat_payload.get("message") or "") + flat_payload["message"] = ( + "⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n" + + original + ).strip() + logger.warning( + "Mattermost: falling back to flat channel delivery for notify-worthy post in %s", + chat_id, + ) + return await self._api_post("posts", flat_payload) + async def _api_put( self, path: str, payload: Dict[str, Any] ) -> Dict[str, Any]: @@ -286,14 +348,12 @@ class MattermostAdapter(BasePlatformAdapter): "channel_id": chat_id, "message": chunk, } - # Thread support: reply_to is the root post ID. - if reply_to and self._reply_mode == "thread": - # Ensure root_id points to the thread root, not a reply. - # Mattermost rejects non-root post IDs as root_id. - resolved_root = await self._resolve_root_id(reply_to) + # Thread support: reply_to or metadata["thread_id"] is the root post ID. + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to create post") last_id = data["id"] @@ -346,7 +406,7 @@ class MattermostAdapter(BasePlatformAdapter): ) -> SendResult: """Download an image and upload it as a file attachment.""" return await self._send_url_as_file( - chat_id, image_url, caption, reply_to, "image" + chat_id, image_url, caption, reply_to, "image", metadata ) async def send_image_file( @@ -359,7 +419,7 @@ class MattermostAdapter(BasePlatformAdapter): ) -> SendResult: """Upload a local image file.""" return await self._send_local_file( - chat_id, image_path, caption, reply_to + chat_id, image_path, caption, reply_to, metadata=metadata ) async def send_document( @@ -373,7 +433,7 @@ class MattermostAdapter(BasePlatformAdapter): ) -> SendResult: """Upload a local file as a document.""" return await self._send_local_file( - chat_id, file_path, caption, reply_to, file_name + chat_id, file_path, caption, reply_to, file_name, metadata ) async def send_voice( @@ -386,7 +446,7 @@ class MattermostAdapter(BasePlatformAdapter): ) -> SendResult: """Upload an audio file.""" return await self._send_local_file( - chat_id, audio_path, caption, reply_to + chat_id, audio_path, caption, reply_to, metadata=metadata ) async def send_video( @@ -399,7 +459,7 @@ class MattermostAdapter(BasePlatformAdapter): ) -> SendResult: """Upload a video file.""" return await self._send_local_file( - chat_id, video_path, caption, reply_to + chat_id, video_path, caption, reply_to, metadata=metadata ) def format_message(self, content: str) -> str: @@ -423,12 +483,13 @@ class MattermostAdapter(BasePlatformAdapter): caption: Optional[str], reply_to: Optional[str], kind: str = "file", + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Download a URL and upload it as a file attachment.""" from tools.url_safety import is_safe_url if not is_safe_url(url): logger.warning("Mattermost: blocked unsafe URL (SSRF protection)") - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) import aiohttp @@ -446,7 +507,7 @@ class MattermostAdapter(BasePlatformAdapter): await asyncio.sleep(1.5 * (attempt + 1)) continue if resp.status >= 400: - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) file_data = await resp.read() ct = resp.content_type or "application/octet-stream" break @@ -455,25 +516,26 @@ class MattermostAdapter(BasePlatformAdapter): await asyncio.sleep(1.5 * (attempt + 1)) continue logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc) - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) if file_data is None: logger.warning("Mattermost: download returned no data for %s", url) - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) file_id = await self._upload_file(chat_id, file_data, fname, ct) if not file_id: - return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata) payload: Dict[str, Any] = { "channel_id": chat_id, "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": - payload["root_id"] = await self._resolve_root_id(reply_to) + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: + payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) @@ -485,6 +547,7 @@ class MattermostAdapter(BasePlatformAdapter): caption: Optional[str], reply_to: Optional[str], file_name: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Upload a local file and attach it to a post.""" import mimetypes @@ -509,10 +572,11 @@ class MattermostAdapter(BasePlatformAdapter): "message": caption or "", "file_ids": [file_id], } - if reply_to and self._reply_mode == "thread": - payload["root_id"] = await self._resolve_root_id(reply_to) + resolved_root = await self._thread_root_for_send(reply_to, metadata) + if resolved_root: + payload["root_id"] = resolved_root - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: return SendResult(success=False, error="Failed to post with file") return SendResult(success=True, message_id=data["id"]) @@ -596,11 +660,14 @@ class MattermostAdapter(BasePlatformAdapter): "message": "\n".join(caption_parts), "file_ids": file_ids, } + resolved_root = await self._thread_root_for_send(None, metadata) + if resolved_root: + payload["root_id"] = resolved_root logger.info( "Mattermost: sending %d image(s) as single post (chunk %d/%d)", len(file_ids), chunk_idx + 1, len(chunks), ) - data = await self._api_post("posts", payload) + data = await self._post_preserving_thread(chat_id, payload, metadata) if not data or "id" not in data: logger.warning("Mattermost: multi-image post failed, falling back") await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) @@ -786,8 +853,16 @@ class MattermostAdapter(BasePlatformAdapter): sender_id = post.get("user_id", "") sender_name = data.get("sender_name", "").lstrip("@") or sender_id - # Thread support: if the post is in a thread, use root_id. + # Thread support: if the post is in a thread, use root_id. In + # thread mode, top-level channel posts are valid roots for progress. thread_id = post.get("root_id") or None + if ( + not thread_id + and self._reply_mode == "thread" + and channel_type_raw != "D" + and post_id + ): + thread_id = post_id # Determine message type. file_ids = post.get("file_ids") or [] @@ -849,6 +924,7 @@ class MattermostAdapter(BasePlatformAdapter): user_id=sender_id, user_name=sender_name, thread_id=thread_id, + message_id=post_id, ) # Per-channel ephemeral prompt diff --git a/tests/gateway/test_mattermost.py b/tests/gateway/test_mattermost.py index cafe5ad68a..9b174a5137 100644 --- a/tests/gateway/test_mattermost.py +++ b/tests/gateway/test_mattermost.py @@ -6,6 +6,30 @@ import pytest from unittest.mock import MagicMock, patch, AsyncMock from gateway.config import Platform, PlatformConfig +from gateway.run import _resolve_progress_thread_id + + +class TestMattermostProgressThreadRouting: + def test_top_level_mattermost_progress_uses_event_message_id(self): + assert _resolve_progress_thread_id( + Platform.MATTERMOST, + source_thread_id=None, + event_message_id="top_post_123", + ) == "top_post_123" + + def test_threaded_mattermost_progress_prefers_existing_thread_root(self): + assert _resolve_progress_thread_id( + Platform.MATTERMOST, + source_thread_id="root_post_123", + event_message_id="reply_post_456", + ) == "root_post_123" + + def test_telegram_progress_does_not_use_message_id_as_thread_id(self): + assert _resolve_progress_thread_id( + Platform.TELEGRAM, + source_thread_id=None, + event_message_id="12345", + ) is None # --------------------------------------------------------------------------- @@ -237,6 +261,92 @@ class TestMattermostSend: payload = self.adapter._session.post.call_args[1]["json"] assert "root_id" not in payload + + @pytest.mark.asyncio + async def test_send_uses_metadata_thread_id_for_progress_messages(self): + """Progress/status messages pass Mattermost thread context via metadata.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "root_post_123", "root_id": ""}) + self.adapter._api_post = AsyncMock(return_value={"id": "progress_post"}) + + result = await self.adapter.send( + "channel_1", + "⚡ terminal...", + metadata={"thread_id": "root_post_123"}, + ) + + assert result.success is True + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "root_post_123" + + @pytest.mark.asyncio + async def test_progress_send_with_invalid_thread_root_never_falls_back_flat(self): + """Tool/status/progress bubbles must stay quiet when the thread is broken.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""}) + self.adapter._last_post_status = 400 + self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id" + self.adapter._api_post = AsyncMock(return_value={}) + + result = await self.adapter.send( + "channel_1", + "⚙️ terminal...", + metadata={"thread_id": "bad_root"}, + ) + + assert result.success is False + assert self.adapter._api_post.call_count == 1 + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "bad_root" + + @pytest.mark.asyncio + async def test_notify_send_with_invalid_thread_root_falls_back_flat_with_warning(self): + """Notify-worthy replies may fall back flat so the answer is not lost.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "bad_root", "root_id": ""}) + self.adapter._last_post_status = 400 + self.adapter._last_post_error = "api.context.invalid_param.app_error: invalid root_id" + self.adapter._api_post = AsyncMock(side_effect=[{}, {"id": "flat_final"}]) + + result = await self.adapter.send( + "channel_1", + "Final answer body", + reply_to="bad_root", + metadata={"notify": True}, + ) + + assert result.success is True + assert result.message_id == "flat_final" + assert self.adapter._api_post.call_count == 2 + threaded_payload = self.adapter._api_post.call_args_list[0][0][1] + flat_payload = self.adapter._api_post.call_args_list[1][0][1] + assert threaded_payload["root_id"] == "bad_root" + assert "root_id" not in flat_payload + assert flat_payload["channel_id"] == "channel_1" + assert "Mattermost thread delivery failed" in flat_payload["message"] + assert "Final answer body" in flat_payload["message"] + + @pytest.mark.asyncio + async def test_notify_send_with_server_error_does_not_fall_back_flat(self): + """Notify fallback is only for broken thread roots, not generic API failures.""" + self.adapter._reply_mode = "thread" + self.adapter._api_get = AsyncMock(return_value={"id": "root_post", "root_id": ""}) + self.adapter._last_post_status = 500 + self.adapter._last_post_error = "Internal Server Error" + self.adapter._api_post = AsyncMock(return_value={}) + + result = await self.adapter.send( + "channel_1", + "Final answer body", + reply_to="root_post", + metadata={"notify": True}, + ) + + assert result.success is False + assert self.adapter._api_post.call_count == 1 + payload = self.adapter._api_post.call_args_list[0][0][1] + assert payload["root_id"] == "root_post" + @pytest.mark.asyncio async def test_send_api_failure(self): """When API returns error, send should return failure.""" @@ -750,3 +860,65 @@ class TestMattermostMediaTypes: assert msg.media_types == ["application/pdf"] assert not msg.media_types[0].startswith("image/") assert not msg.media_types[0].startswith("audio/") + + + +@pytest.mark.asyncio +async def test_mattermost_top_level_channel_post_is_thread_root(): + adapter = _make_adapter() + adapter._reply_mode = "thread" + adapter._bot_user_id = "bot_user_id" + adapter._bot_username = "hermes-bot" + adapter.handle_message = AsyncMock() + post_data = { + "id": "top_post_123", + "user_id": "user_123", + "channel_id": "chan_456", + "message": "@hermes-bot start work", + "root_id": "", + } + event = { + "event": "posted", + "data": { + "post": json.dumps(post_data), + "channel_type": "O", + "sender_name": "@alice", + }, + } + + await adapter._handle_ws_event(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.source.thread_id == "top_post_123" + assert msg_event.source.message_id == "top_post_123" + assert msg_event.message_id == "top_post_123" + + +@pytest.mark.asyncio +async def test_mattermost_dm_post_does_not_seed_thread_root(): + adapter = _make_adapter() + adapter._reply_mode = "thread" + adapter._bot_user_id = "bot_user_id" + adapter._bot_username = "hermes-bot" + adapter.handle_message = AsyncMock() + post_data = { + "id": "dm_post_123", + "user_id": "user_123", + "channel_id": "dm_chan", + "message": "hello", + "root_id": "", + } + event = { + "event": "posted", + "data": { + "post": json.dumps(post_data), + "channel_type": "D", + "sender_name": "@alice", + }, + } + + await adapter._handle_ws_event(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.source.thread_id is None + assert msg_event.source.message_id == "dm_post_123" From 5bfed0fe071ae102f3a8bb96f28ac5cb5f0bba04 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:28:42 -0700 Subject: [PATCH 68/92] feat(skills): add optional payments skills (Stripe Link, MPP, Projects) (#31343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): add optional payments skills (Stripe Link, MPP, Projects) Adds four optional skills under optional-skills/payments/ wrapping the Stripe Link CLI, the Machine Payments Protocol (MPP) clients, and the Stripe Projects CLI plugin. Plus a router skill (payments) that picks between them based on user intent. All four are gated [linux, macos] — Stripe's Link CLI does not yet support Windows. The other CLIs (mppx, stripe projects) are cross-platform on paper but the payments cluster moves as a unit until Link CLI gains Windows support. Skills: - stripe-link-cli - one-time virtual cards + Shared Payment Tokens - mpp-agent - HTTP 402 payments via mppx/Tempo/Privy/AgentCash - stripe-projects - provision SaaS services + credential sync - payments - router/index skill for the cluster Hard invariants encoded in every skill: - Card PANs/wallet keys never enter agent transcripts, logs, or memory - Spend approvals are not self-bypassable (Link app / wallet UI / CLI prompt) - Final totals confirmed with user before any --request-approval call - Credential output files cleaned up after one-time use Zero core touches. Skills install via: hermes skills install official/payments/ * chore(skills/payments): drop router skill — skills shouldn't depend on other skills Removed optional-skills/payments/payments/ — the router skill that existed to hand off between stripe-link-cli, mpp-agent, and stripe-projects. Per project convention: skills should be independently loadable; a router is a footgun because (a) it assumes the loader will follow its recommendation rather than just loading what the user asked for, and (b) it duplicates the trigger logic that already lives in each sub-skill's '## When to Use' section. The three remaining skills declare their own triggers and routing hints. The optional-skills catalog still groups them under '## payments', which is the appropriate place for cluster-level discoverability. Also drops 'payments' from each remaining skill's 'related_skills' list and removes the corresponding entries from the docs catalog + sidebars. * feat(skills/payments): fold in danhill-stripe review feedback - mpp-agent: add link-cli as a client option (when Link is already set up, or the 402 challenge advertises method="stripe") - stripe-link-cli: reframe Link account / payment method / approval app as first-run setup, not hard preconditions (CLI configures them on first run) - regenerate the two affected optional-skills docs pages --- optional-skills/payments/mpp-agent/SKILL.md | 124 +++++++++++ .../payments/stripe-link-cli/SKILL.md | 184 ++++++++++++++++ .../payments/stripe-projects/SKILL.md | 120 +++++++++++ .../docs/reference/optional-skills-catalog.md | 8 + .../optional/payments/payments-mpp-agent.md | 142 ++++++++++++ .../payments/payments-stripe-link-cli.md | 202 ++++++++++++++++++ .../payments/payments-stripe-projects.md | 138 ++++++++++++ website/sidebars.ts | 12 ++ 8 files changed, 930 insertions(+) create mode 100644 optional-skills/payments/mpp-agent/SKILL.md create mode 100644 optional-skills/payments/stripe-link-cli/SKILL.md create mode 100644 optional-skills/payments/stripe-projects/SKILL.md create mode 100644 website/docs/user-guide/skills/optional/payments/payments-mpp-agent.md create mode 100644 website/docs/user-guide/skills/optional/payments/payments-stripe-link-cli.md create mode 100644 website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md diff --git a/optional-skills/payments/mpp-agent/SKILL.md b/optional-skills/payments/mpp-agent/SKILL.md new file mode 100644 index 0000000000..f4817af047 --- /dev/null +++ b/optional-skills/payments/mpp-agent/SKILL.md @@ -0,0 +1,124 @@ +--- +name: mpp-agent +description: Pay HTTP 402 APIs via Machine Payments Protocol (MPP). +version: 0.1.0 +author: Teknium (teknium1), Hermes Agent +license: MIT +platforms: [linux, macos] +metadata: + hermes: + tags: [Payments, MPP, HTTP-402, Tempo, Stripe] + related_skills: [stripe-link-cli, stripe-projects] +--- + +# MPP Agent Skill + +Wraps the Machine Payments Protocol (MPP, https://mpp.dev) clients so Hermes can pay for per-request API access against servers that respond with `HTTP 402 Payment Required`. + +Three client options, all distributed via npm. Pick the lightest one that solves the user's need. Gated `[linux, macos]` while the broader payments tooling matures on Windows. + +## When to Use + +- A merchant API returns `HTTP 402` with a `www-authenticate` header — and the user wants to actually pay it, not just log the response. +- The user asks to "pay per request", "set up an agent wallet", "use Tempo / Privy / AgentCash", or wants to discover MPP-priced services. +- A Stripe Link spend has produced a Shared Payment Token (SPT) and the agent needs to attach it to the 402 challenge — in that flow, prefer `link-cli mpp pay` (see the `stripe-link-cli` skill). + +## Choosing a client + +| Tool | When | Setup | +|---|---|---| +| `link-cli` | User already has Stripe Link set up, or the 402 challenge advertises `method="stripe"` | see the `stripe-link-cli` skill | +| Tempo Wallet | MPP services with spend controls, service discovery | `tempo wallet login` | +| Privy Agent CLI | Multi-chain wallets, browser-based funding | `privy-agent-wallets login` | +| AgentCash | 300+ pre-priced APIs via one USDC.e balance | `npx agentcash onboard` | +| `mppx` | Dev + debugging, smallest dep surface | `npm install -g mppx` then `mppx account create` | + +Default: if the user already has Stripe Link configured or the 402 challenge specifies `method="stripe"`, use `link-cli mpp pay` (the `stripe-link-cli` skill). Otherwise `mppx` for one-off paid calls and debugging, and Tempo Wallet when the user wants persistent spend controls. + +## Prerequisites + +- Node.js 20+ on `PATH` +- A funded wallet (Tempo / Privy / AgentCash) OR an `mppx` account +- For Tempo / Privy / AgentCash: follow their respective onboarding skills: + - `https://tempo.xyz/SKILL.md` + - `https://agents.privy.io/skill.md` + - `https://agentcash.dev/skill.md` + +Use `web_extract` to fetch any of those SKILL.md files if the user picks one. + +## Procedure (mppx, fastest path) + +Run all commands through the `terminal` tool. + +### 1. Install + create an account + +``` +npm install -g mppx +mppx account create +``` + +Store the resulting account credentials wherever the CLI tells you (the CLI writes them under its own config — do not paste them into the agent transcript). + +### 2. Inspect the merchant's 402 challenge + +If the user gives you a URL, probe it first to confirm it actually speaks MPP: + +``` +curl -i +``` + +A real MPP 402 looks like: + +``` +HTTP/1.1 402 Payment Required +www-authenticate: tempo amount=0.1 currency=... +``` + +### 3. Pay the request + +``` +mppx +``` + +For non-GET methods or request bodies: + +``` +mppx --method POST --data '' +``` + +`mppx` handles the 402 challenge/credential dance automatically and prints the merchant's actual response on success. + +### 4. Verify the receipt + +`mppx` attaches the receipt header automatically. To inspect: + +``` +mppx -v +``` + +## Procedure (Tempo Wallet) + +The Tempo Wallet skill at https://tempo.xyz/SKILL.md is the canonical reference; fetch it with `web_extract` and follow it. Headline: + +``` +tempo wallet login +tempo wallet pay +``` + +Spend controls and service discovery live in the wallet UI at https://wallet.tempo.xyz. + +## Pitfalls + +- **`HTTP 402` without `method="stripe"` cannot be paid by Stripe Link.** If the challenge advertises only Tempo / other methods, use `mppx` (or whichever wallet matches) — Link will reject it. Conversely, if it advertises `method="stripe"`, prefer Link via the `stripe-link-cli` skill so the spend goes through the user's approved card. +- **Multiple challenges in one header.** `www-authenticate` may list several methods (e.g. `tempo, stripe`). The Link CLI's `mpp decode` will pick the Stripe one; `mppx` will pick Tempo. There's no single "right" client — pick by which wallet the user has funded. +- **Zero-amount challenges.** Some MPP endpoints charge `$0.00` and just want a proof credential. These work without a funded wallet. Don't refuse them as "broken." +- **Wallet keys never enter agent context.** All four clients store keys under their own config dirs (or generate per-session ephemeral keypairs, in Privy's case). Do not `cat`/`read_file` them. +- **Server-side MPP is a different skill.** If the user wants to ADD 402 to their own API, this skill is wrong — point them at https://mpp.dev/quickstart/server and the `mppx/nextjs` / `mppx/hono` / `mppx/express` / `mppx/elysia` middlewares. A dedicated `mpp-server` skill may land later. + +## Verification + +``` +mppx --version && mppx account list +``` + +Exit code 0 means installed and an account exists. diff --git a/optional-skills/payments/stripe-link-cli/SKILL.md b/optional-skills/payments/stripe-link-cli/SKILL.md new file mode 100644 index 0000000000..a223382967 --- /dev/null +++ b/optional-skills/payments/stripe-link-cli/SKILL.md @@ -0,0 +1,184 @@ +--- +name: stripe-link-cli +description: Agent payments via Stripe Link — cards, SPT, approvals. +version: 0.1.0 +author: Teknium (teknium1), Hermes Agent +license: MIT +platforms: [linux, macos] +metadata: + hermes: + tags: [Payments, Stripe, Link, Checkout, MPP] + related_skills: [mpp-agent, stripe-projects] +--- + +# Stripe Link CLI Skill + +Wraps [@stripe/link-cli](https://github.com/stripe/link-cli) so Hermes can complete purchases on the user's behalf using one-time-use virtual cards or Shared Payment Tokens (SPT). Every spend is gated by an in-app approval in the Link mobile/web app — Hermes cannot self-approve. + +US-only at the moment (Link account requirement). Windows is not supported by the upstream CLI — this skill is gated `[linux, macos]`. + +## When to Use + +Trigger phrases: + +- "buy X", "pay for X", "make a purchase", "complete checkout" +- "get me a card", "I need a payment method" +- "log in to Link", "connect my Link wallet" +- HTTP 402 response from a merchant API with `www-authenticate: ... method="stripe"` + +If the user wants a paid API call (HTTP 402, no checkout form), the `card` path is wrong — use SPT via this same skill, or hand off to the `mpp-agent` skill. + +## Prerequisites + +- Node.js 20+ available on `PATH` (`node --version`) +- US-based (Link account requirement) + +The Link account, payment method, and spend-approval app do NOT need to be set up before Hermes attempts to pay — the CLI walks the user through them on first run: + +- A Link account at https://app.link.com — created/linked during first `link-cli` auth +- At least one payment method — added during first run at https://app.link.com/wallet +- The Link mobile/web app — opened to approve the first spend request when it's made + +No env vars required — auth state is stored locally by the CLI under its own config directory. + +## Install + +Install once, globally: + +``` +npm install -g @stripe/link-cli +``` + +Or invoke ad-hoc via `npx @stripe/link-cli`. The skill below uses the installed `link-cli` form. + +## How to Run + +All commands run through the `terminal` tool. The CLI auto-detects non-TTY callers and emits compact `toon` output by default — fine for the model. Pass `--format json` if a step needs structured fields. + +Discover commands: `link-cli --llms-full`. +Get a command's schema before invoking: `link-cli --schema`. + +## Procedure + +### 1. Check / establish auth + +``` +link-cli auth status +``` + +If not authenticated, log in with a clear client name (this label shows in the user's Link app): + +``` +link-cli auth login --client-name "Hermes" --interval 5 --timeout 300 +``` + +The `--interval`/`--timeout` form polls inline so the agent doesn't need to manage a `_next` step. Print the verification URL + phrase to the user and wait for the CLI to return. + +**Do not proceed past this step until `auth status` confirms login.** + +### 2. Evaluate the merchant before creating a spend request + +Decide the credential type: + +| Merchant surface | `--credential-type` | +|---|---| +| Standard web checkout form / Stripe Elements | `card` (default) | +| Returns HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` | +| Returns HTTP 402 without `method="stripe"` | unsupported — stop | + +For 402 responses, do NOT decode the challenge manually. Pass the raw header: + +``` +link-cli mpp decode --challenge '' +``` + +This validates the challenge and extracts the network ID + decoded request body. + +### 3. List payment methods + shipping + +``` +link-cli payment-methods list +link-cli shipping-address list +``` + +Use the first entry unless the user specifies otherwise. The `id` from `payment-methods list` is the `--payment-method-id` in the next step. + +### 4. Create the spend request + +Confirm the final total with the user before issuing this command. Amounts are in cents. + +``` +link-cli spend-request create \ + --payment-method-id \ + --merchant-name "" \ + --merchant-url "" \ + --context "" \ + --amount \ + --line-item "name:,unit_amount:,quantity:1" \ + --total "type:total,display_text:Total,amount:" \ + --request-approval +``` + +For MPP merchants add `--credential-type shared_payment_token`. + +`--request-approval` pings the user's Link app and polls until they approve or deny. The CLI exits non-zero on deny / timeout. + +### 5. Retrieve the credential — SECURELY + +**Do not print card details to stdout.** Use `--output-file` so the PAN never enters the agent's transcript or logs: + +``` +link-cli spend-request retrieve \ + --include card \ + --output-file /tmp/link-card.json \ + --format json +``` + +The file is written with `0600` perms; stdout shows only redacted fields (brand, last4, expiry) plus a `card_output_file` path. + +### 6. Use the credential + +- For web checkout: hand the file path to the user, OR pass it to a browser-driving tool that fills the form directly from disk. Never `read_file` or `cat` the card file into the agent's reasoning context. +- For MPP merchants: + + ``` + link-cli mpp pay \ + --spend-request-id \ + --method POST \ + --data '' + ``` + +### 7. Clean up + +Delete the card file as soon as the purchase is done: + +``` +rm -f /tmp/link-card.json +``` + +## Optional: run as an MCP server instead + +`@stripe/link-cli --mcp` exposes the same commands as MCP tools over stdio. To register it with Hermes' native MCP: + +``` +hermes mcp add stripe-link --command "npx" --args "@stripe/link-cli --mcp" +``` + +Then `hermes mcp list` should show `stripe-link`. The same approval rules apply — MCP doesn't bypass the Link app approval step. + +## Pitfalls + +- **US-only.** Outside the US, `auth login` will fail. Tell the user, don't keep retrying. +- **Card PAN must never enter agent context.** Use `--output-file` every time. If you've already retrieved without it, immediately `link-cli auth logout` is not enough — the card is one-time-use but rotate hygiene matters. +- **`--request-approval` blocks until the user acts.** If the user is asleep, the CLI will hit its timeout. Set expectations. +- **Multi-step `_next` commands.** Some commands return `_next.command` that must be executed to continue. When in doubt, prefer the inline-polling flags (`--interval`/`--timeout`). +- **Output format defaults to `toon`** in non-TTY mode. Fine for prose, but if a downstream step needs to parse a specific field, pass `--format json`. +- **Don't default to `card`.** The merchant-evaluation step (Section 2) exists because picking the wrong credential type fails the purchase silently or leaks more data than needed. + +## Verification + +``` +link-cli --version && link-cli auth status +``` + +Exit code 0 means installed and logged in. diff --git a/optional-skills/payments/stripe-projects/SKILL.md b/optional-skills/payments/stripe-projects/SKILL.md new file mode 100644 index 0000000000..d1b30d8987 --- /dev/null +++ b/optional-skills/payments/stripe-projects/SKILL.md @@ -0,0 +1,120 @@ +--- +name: stripe-projects +description: Provision SaaS services + sync creds via Stripe Projects. +version: 0.1.0 +author: Teknium (teknium1), Hermes Agent +license: MIT +platforms: [linux, macos] +metadata: + hermes: + tags: [Payments, Stripe, Projects, Provisioning, Infrastructure] + related_skills: [stripe-link-cli, mpp-agent] +--- + +# Stripe Projects Skill + +Wraps the [Stripe Projects](https://projects.dev) CLI plugin so Hermes can provision SaaS services (Neon, Twilio, Vercel, etc.), generate and sync credentials into the user's `.env`, and manage billing across providers from one place. + +Gated `[linux, macos]` while the broader payments cluster matures on Windows. The Stripe CLI itself is cross-platform; this gate is a posture for the cluster, not a hard limit. + +## When to Use + +Trigger phrases: + +- "set up ", "provision ", "create a database" +- "give me a for this project" +- "manage my stack credentials", "rotate this key", "upgrade my plan" +- "what providers can I add?" + +If the user already has the service set up manually and just wants to use it, this skill is not the right entry point. + +## Prerequisites + +- Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install) +- Stripe Projects plugin installed +- A Stripe account, logged in via `stripe login` + +## Install + +macOS: + +``` +brew install stripe/stripe-cli/stripe +stripe plugin install projects +``` + +Linux: follow the platform-specific install at https://docs.stripe.com/stripe-cli/install, then: + +``` +stripe plugin install projects +``` + +## How to Run + +All commands run through the `terminal` tool from inside the user's project directory (the CLI writes `.env` and `.projects/vault/vault.json` into the CWD). + +## Procedure + +### 1. Initialize the project + +``` +cd +stripe projects init +``` + +This creates `.projects/vault/vault.json` (encrypted credential store) and prepares the project to receive providers. + +### 2. Discover available providers + +``` +stripe projects catalog +``` + +Lists every provider Stripe Projects supports — databases, hosting, auth, AI, analytics, messaging, etc. + +### 3. Add a service + +``` +stripe projects add / +``` + +Examples: + +- `stripe projects add neon/postgres` +- `stripe projects add twilio/sms` +- `stripe projects add runloop/sandbox` + +The CLI provisions the service in the user's own account with the provider, generates credentials, syncs them into `.env`, and records the resource in the vault. The user may need to confirm a tier selection or pricing prompt. + +### 4. Verify + +``` +stripe projects list +``` + +Should show the newly added provider and its `.env` keys. + +### 5. Manage / upgrade / remove + +``` +stripe projects upgrade # tier change +stripe projects remove # deprovision +stripe projects rotate # rotate credentials +``` + +## Pitfalls + +- **`.env` writes are real writes.** The CLI appends to whatever `.env` is in the project root. If the user's `.env` is gitignored (normal), the keys land safely; if not, this skill could be a credential-leak vector. Always check `.gitignore` first. +- **Per-project state.** `.projects/vault/vault.json` is per-project. Provisioning the same service in two different projects creates two separate resources — and two bills. +- **Billing happens on Stripe's side.** Tier prompts during `add`/`upgrade` are real charges; surface them to the user before confirming. +- **Provider availability changes.** The catalog grows; if a provider the user names isn't listed, `stripe projects catalog | grep ` first instead of failing the `add` call. +- **Credentials in vault are encrypted but `.env` is plaintext.** Standard `.env` hygiene applies — never commit it. +- **Removing a service does NOT always destroy the underlying resource.** Some providers leave a paused/dormant resource behind. Check the provider's own dashboard after `remove` for high-cost services (managed databases especially). + +## Verification + +``` +stripe projects --version && stripe projects list +``` + +Exit code 0 inside an initialized project means the plugin is healthy. diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 5e44cba8eb..89a4f47fe8 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -162,6 +162,14 @@ hermes skills uninstall | [**unsloth**](/docs/user-guide/skills/optional/mlops/mlops-training-unsloth) | Unsloth: 2-5x faster LoRA/QLoRA fine-tuning, less VRAM. | | [**whisper**](/docs/user-guide/skills/optional/mlops/mlops-whisper) | OpenAI's general-purpose speech recognition model. Supports 99 languages, transcription, translation to English, and language identification. Six model sizes from tiny (39M params) to large (1550M params). Use for speech-to-text, podcast... | +## payments + +| Skill | Description | +|-------|-------------| +| [**mpp-agent**](/docs/user-guide/skills/optional/payments/payments-mpp-agent) | Pay HTTP 402 APIs via Machine Payments Protocol (MPP). | +| [**stripe-link-cli**](/docs/user-guide/skills/optional/payments/payments-stripe-link-cli) | Agent payments via Stripe Link — cards, SPT, approvals. | +| [**stripe-projects**](/docs/user-guide/skills/optional/payments/payments-stripe-projects) | Provision SaaS services + sync creds via Stripe Projects. | + ## productivity | Skill | Description | diff --git a/website/docs/user-guide/skills/optional/payments/payments-mpp-agent.md b/website/docs/user-guide/skills/optional/payments/payments-mpp-agent.md new file mode 100644 index 0000000000..ee2ff286e2 --- /dev/null +++ b/website/docs/user-guide/skills/optional/payments/payments-mpp-agent.md @@ -0,0 +1,142 @@ +--- +title: "Mpp Agent — Pay HTTP 402 APIs via Machine Payments Protocol (MPP)" +sidebar_label: "Mpp Agent" +description: "Pay HTTP 402 APIs via Machine Payments Protocol (MPP)" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Mpp Agent + +Pay HTTP 402 APIs via Machine Payments Protocol (MPP). + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/payments/mpp-agent` | +| Path | `optional-skills/payments/mpp-agent` | +| Version | `0.1.0` | +| Author | Teknium (teknium1), Hermes Agent | +| License | MIT | +| Platforms | linux, macos | +| Tags | `Payments`, `MPP`, `HTTP-402`, `Tempo`, `Stripe` | +| Related skills | [`stripe-link-cli`](/docs/user-guide/skills/optional/payments/payments-stripe-link-cli), [`stripe-projects`](/docs/user-guide/skills/optional/payments/payments-stripe-projects) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# MPP Agent Skill + +Wraps the Machine Payments Protocol (MPP, https://mpp.dev) clients so Hermes can pay for per-request API access against servers that respond with `HTTP 402 Payment Required`. + +Three client options, all distributed via npm. Pick the lightest one that solves the user's need. Gated `[linux, macos]` while the broader payments tooling matures on Windows. + +## When to Use + +- A merchant API returns `HTTP 402` with a `www-authenticate` header — and the user wants to actually pay it, not just log the response. +- The user asks to "pay per request", "set up an agent wallet", "use Tempo / Privy / AgentCash", or wants to discover MPP-priced services. +- A Stripe Link spend has produced a Shared Payment Token (SPT) and the agent needs to attach it to the 402 challenge — in that flow, prefer `link-cli mpp pay` (see the `stripe-link-cli` skill). + +## Choosing a client + +| Tool | When | Setup | +|---|---|---| +| `link-cli` | User already has Stripe Link set up, or the 402 challenge advertises `method="stripe"` | see the `stripe-link-cli` skill | +| Tempo Wallet | MPP services with spend controls, service discovery | `tempo wallet login` | +| Privy Agent CLI | Multi-chain wallets, browser-based funding | `privy-agent-wallets login` | +| AgentCash | 300+ pre-priced APIs via one USDC.e balance | `npx agentcash onboard` | +| `mppx` | Dev + debugging, smallest dep surface | `npm install -g mppx` then `mppx account create` | + +Default: if the user already has Stripe Link configured or the 402 challenge specifies `method="stripe"`, use `link-cli mpp pay` (the `stripe-link-cli` skill). Otherwise `mppx` for one-off paid calls and debugging, and Tempo Wallet when the user wants persistent spend controls. + +## Prerequisites + +- Node.js 20+ on `PATH` +- A funded wallet (Tempo / Privy / AgentCash) OR an `mppx` account +- For Tempo / Privy / AgentCash: follow their respective onboarding skills: + - `https://tempo.xyz/SKILL.md` + - `https://agents.privy.io/skill.md` + - `https://agentcash.dev/skill.md` + +Use `web_extract` to fetch any of those SKILL.md files if the user picks one. + +## Procedure (mppx, fastest path) + +Run all commands through the `terminal` tool. + +### 1. Install + create an account + +``` +npm install -g mppx +mppx account create +``` + +Store the resulting account credentials wherever the CLI tells you (the CLI writes them under its own config — do not paste them into the agent transcript). + +### 2. Inspect the merchant's 402 challenge + +If the user gives you a URL, probe it first to confirm it actually speaks MPP: + +``` +curl -i +``` + +A real MPP 402 looks like: + +``` +HTTP/1.1 402 Payment Required +www-authenticate: tempo amount=0.1 currency=... +``` + +### 3. Pay the request + +``` +mppx +``` + +For non-GET methods or request bodies: + +``` +mppx --method POST --data '' +``` + +`mppx` handles the 402 challenge/credential dance automatically and prints the merchant's actual response on success. + +### 4. Verify the receipt + +`mppx` attaches the receipt header automatically. To inspect: + +``` +mppx -v +``` + +## Procedure (Tempo Wallet) + +The Tempo Wallet skill at https://tempo.xyz/SKILL.md is the canonical reference; fetch it with `web_extract` and follow it. Headline: + +``` +tempo wallet login +tempo wallet pay +``` + +Spend controls and service discovery live in the wallet UI at https://wallet.tempo.xyz. + +## Pitfalls + +- **`HTTP 402` without `method="stripe"` cannot be paid by Stripe Link.** If the challenge advertises only Tempo / other methods, use `mppx` (or whichever wallet matches) — Link will reject it. Conversely, if it advertises `method="stripe"`, prefer Link via the `stripe-link-cli` skill so the spend goes through the user's approved card. +- **Multiple challenges in one header.** `www-authenticate` may list several methods (e.g. `tempo, stripe`). The Link CLI's `mpp decode` will pick the Stripe one; `mppx` will pick Tempo. There's no single "right" client — pick by which wallet the user has funded. +- **Zero-amount challenges.** Some MPP endpoints charge `$0.00` and just want a proof credential. These work without a funded wallet. Don't refuse them as "broken." +- **Wallet keys never enter agent context.** All four clients store keys under their own config dirs (or generate per-session ephemeral keypairs, in Privy's case). Do not `cat`/`read_file` them. +- **Server-side MPP is a different skill.** If the user wants to ADD 402 to their own API, this skill is wrong — point them at https://mpp.dev/quickstart/server and the `mppx/nextjs` / `mppx/hono` / `mppx/express` / `mppx/elysia` middlewares. A dedicated `mpp-server` skill may land later. + +## Verification + +``` +mppx --version && mppx account list +``` + +Exit code 0 means installed and an account exists. diff --git a/website/docs/user-guide/skills/optional/payments/payments-stripe-link-cli.md b/website/docs/user-guide/skills/optional/payments/payments-stripe-link-cli.md new file mode 100644 index 0000000000..fdabbab6cb --- /dev/null +++ b/website/docs/user-guide/skills/optional/payments/payments-stripe-link-cli.md @@ -0,0 +1,202 @@ +--- +title: "Stripe Link Cli — Agent payments via Stripe Link — cards, SPT, approvals" +sidebar_label: "Stripe Link Cli" +description: "Agent payments via Stripe Link — cards, SPT, approvals" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Stripe Link Cli + +Agent payments via Stripe Link — cards, SPT, approvals. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/payments/stripe-link-cli` | +| Path | `optional-skills/payments/stripe-link-cli` | +| Version | `0.1.0` | +| Author | Teknium (teknium1), Hermes Agent | +| License | MIT | +| Platforms | linux, macos | +| Tags | `Payments`, `Stripe`, `Link`, `Checkout`, `MPP` | +| Related skills | [`mpp-agent`](/docs/user-guide/skills/optional/payments/payments-mpp-agent), [`stripe-projects`](/docs/user-guide/skills/optional/payments/payments-stripe-projects) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Stripe Link CLI Skill + +Wraps [@stripe/link-cli](https://github.com/stripe/link-cli) so Hermes can complete purchases on the user's behalf using one-time-use virtual cards or Shared Payment Tokens (SPT). Every spend is gated by an in-app approval in the Link mobile/web app — Hermes cannot self-approve. + +US-only at the moment (Link account requirement). Windows is not supported by the upstream CLI — this skill is gated `[linux, macos]`. + +## When to Use + +Trigger phrases: + +- "buy X", "pay for X", "make a purchase", "complete checkout" +- "get me a card", "I need a payment method" +- "log in to Link", "connect my Link wallet" +- HTTP 402 response from a merchant API with `www-authenticate: ... method="stripe"` + +If the user wants a paid API call (HTTP 402, no checkout form), the `card` path is wrong — use SPT via this same skill, or hand off to the `mpp-agent` skill. + +## Prerequisites + +- Node.js 20+ available on `PATH` (`node --version`) +- US-based (Link account requirement) + +The Link account, payment method, and spend-approval app do NOT need to be set up before Hermes attempts to pay — the CLI walks the user through them on first run: + +- A Link account at https://app.link.com — created/linked during first `link-cli` auth +- At least one payment method — added during first run at https://app.link.com/wallet +- The Link mobile/web app — opened to approve the first spend request when it's made + +No env vars required — auth state is stored locally by the CLI under its own config directory. + +## Install + +Install once, globally: + +``` +npm install -g @stripe/link-cli +``` + +Or invoke ad-hoc via `npx @stripe/link-cli`. The skill below uses the installed `link-cli` form. + +## How to Run + +All commands run through the `terminal` tool. The CLI auto-detects non-TTY callers and emits compact `toon` output by default — fine for the model. Pass `--format json` if a step needs structured fields. + +Discover commands: `link-cli --llms-full`. +Get a command's schema before invoking: `link-cli --schema`. + +## Procedure + +### 1. Check / establish auth + +``` +link-cli auth status +``` + +If not authenticated, log in with a clear client name (this label shows in the user's Link app): + +``` +link-cli auth login --client-name "Hermes" --interval 5 --timeout 300 +``` + +The `--interval`/`--timeout` form polls inline so the agent doesn't need to manage a `_next` step. Print the verification URL + phrase to the user and wait for the CLI to return. + +**Do not proceed past this step until `auth status` confirms login.** + +### 2. Evaluate the merchant before creating a spend request + +Decide the credential type: + +| Merchant surface | `--credential-type` | +|---|---| +| Standard web checkout form / Stripe Elements | `card` (default) | +| Returns HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` | +| Returns HTTP 402 without `method="stripe"` | unsupported — stop | + +For 402 responses, do NOT decode the challenge manually. Pass the raw header: + +``` +link-cli mpp decode --challenge '' +``` + +This validates the challenge and extracts the network ID + decoded request body. + +### 3. List payment methods + shipping + +``` +link-cli payment-methods list +link-cli shipping-address list +``` + +Use the first entry unless the user specifies otherwise. The `id` from `payment-methods list` is the `--payment-method-id` in the next step. + +### 4. Create the spend request + +Confirm the final total with the user before issuing this command. Amounts are in cents. + +``` +link-cli spend-request create \ + --payment-method-id \ + --merchant-name "" \ + --merchant-url "" \ + --context "" \ + --amount \ + --line-item "name:,unit_amount:,quantity:1" \ + --total "type:total,display_text:Total,amount:" \ + --request-approval +``` + +For MPP merchants add `--credential-type shared_payment_token`. + +`--request-approval` pings the user's Link app and polls until they approve or deny. The CLI exits non-zero on deny / timeout. + +### 5. Retrieve the credential — SECURELY + +**Do not print card details to stdout.** Use `--output-file` so the PAN never enters the agent's transcript or logs: + +``` +link-cli spend-request retrieve \ + --include card \ + --output-file /tmp/link-card.json \ + --format json +``` + +The file is written with `0600` perms; stdout shows only redacted fields (brand, last4, expiry) plus a `card_output_file` path. + +### 6. Use the credential + +- For web checkout: hand the file path to the user, OR pass it to a browser-driving tool that fills the form directly from disk. Never `read_file` or `cat` the card file into the agent's reasoning context. +- For MPP merchants: + + ``` + link-cli mpp pay \ + --spend-request-id \ + --method POST \ + --data '' + ``` + +### 7. Clean up + +Delete the card file as soon as the purchase is done: + +``` +rm -f /tmp/link-card.json +``` + +## Optional: run as an MCP server instead + +`@stripe/link-cli --mcp` exposes the same commands as MCP tools over stdio. To register it with Hermes' native MCP: + +``` +hermes mcp add stripe-link --command "npx" --args "@stripe/link-cli --mcp" +``` + +Then `hermes mcp list` should show `stripe-link`. The same approval rules apply — MCP doesn't bypass the Link app approval step. + +## Pitfalls + +- **US-only.** Outside the US, `auth login` will fail. Tell the user, don't keep retrying. +- **Card PAN must never enter agent context.** Use `--output-file` every time. If you've already retrieved without it, immediately `link-cli auth logout` is not enough — the card is one-time-use but rotate hygiene matters. +- **`--request-approval` blocks until the user acts.** If the user is asleep, the CLI will hit its timeout. Set expectations. +- **Multi-step `_next` commands.** Some commands return `_next.command` that must be executed to continue. When in doubt, prefer the inline-polling flags (`--interval`/`--timeout`). +- **Output format defaults to `toon`** in non-TTY mode. Fine for prose, but if a downstream step needs to parse a specific field, pass `--format json`. +- **Don't default to `card`.** The merchant-evaluation step (Section 2) exists because picking the wrong credential type fails the purchase silently or leaks more data than needed. + +## Verification + +``` +link-cli --version && link-cli auth status +``` + +Exit code 0 means installed and logged in. diff --git a/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md new file mode 100644 index 0000000000..5ee426361a --- /dev/null +++ b/website/docs/user-guide/skills/optional/payments/payments-stripe-projects.md @@ -0,0 +1,138 @@ +--- +title: "Stripe Projects — Provision SaaS services + sync creds via Stripe Projects" +sidebar_label: "Stripe Projects" +description: "Provision SaaS services + sync creds via Stripe Projects" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Stripe Projects + +Provision SaaS services + sync creds via Stripe Projects. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/payments/stripe-projects` | +| Path | `optional-skills/payments/stripe-projects` | +| Version | `0.1.0` | +| Author | Teknium (teknium1), Hermes Agent | +| License | MIT | +| Platforms | linux, macos | +| Tags | `Payments`, `Stripe`, `Projects`, `Provisioning`, `Infrastructure` | +| Related skills | [`stripe-link-cli`](/docs/user-guide/skills/optional/payments/payments-stripe-link-cli), [`mpp-agent`](/docs/user-guide/skills/optional/payments/payments-mpp-agent) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Stripe Projects Skill + +Wraps the [Stripe Projects](https://projects.dev) CLI plugin so Hermes can provision SaaS services (Neon, Twilio, Vercel, etc.), generate and sync credentials into the user's `.env`, and manage billing across providers from one place. + +Gated `[linux, macos]` while the broader payments cluster matures on Windows. The Stripe CLI itself is cross-platform; this gate is a posture for the cluster, not a hard limit. + +## When to Use + +Trigger phrases: + +- "set up <provider>", "provision <Neon|Twilio|Vercel|...>", "create a database" +- "give me a <Postgres|Redis|Twilio number|...> for this project" +- "manage my stack credentials", "rotate this key", "upgrade my plan" +- "what providers can I add?" + +If the user already has the service set up manually and just wants to use it, this skill is not the right entry point. + +## Prerequisites + +- Stripe CLI installed (Homebrew on macOS, package manager on Linux, or download from https://docs.stripe.com/stripe-cli/install) +- Stripe Projects plugin installed +- A Stripe account, logged in via `stripe login` + +## Install + +macOS: + +``` +brew install stripe/stripe-cli/stripe +stripe plugin install projects +``` + +Linux: follow the platform-specific install at https://docs.stripe.com/stripe-cli/install, then: + +``` +stripe plugin install projects +``` + +## How to Run + +All commands run through the `terminal` tool from inside the user's project directory (the CLI writes `.env` and `.projects/vault/vault.json` into the CWD). + +## Procedure + +### 1. Initialize the project + +``` +cd +stripe projects init +``` + +This creates `.projects/vault/vault.json` (encrypted credential store) and prepares the project to receive providers. + +### 2. Discover available providers + +``` +stripe projects catalog +``` + +Lists every provider Stripe Projects supports — databases, hosting, auth, AI, analytics, messaging, etc. + +### 3. Add a service + +``` +stripe projects add / +``` + +Examples: + +- `stripe projects add neon/postgres` +- `stripe projects add twilio/sms` +- `stripe projects add runloop/sandbox` + +The CLI provisions the service in the user's own account with the provider, generates credentials, syncs them into `.env`, and records the resource in the vault. The user may need to confirm a tier selection or pricing prompt. + +### 4. Verify + +``` +stripe projects list +``` + +Should show the newly added provider and its `.env` keys. + +### 5. Manage / upgrade / remove + +``` +stripe projects upgrade # tier change +stripe projects remove # deprovision +stripe projects rotate # rotate credentials +``` + +## Pitfalls + +- **`.env` writes are real writes.** The CLI appends to whatever `.env` is in the project root. If the user's `.env` is gitignored (normal), the keys land safely; if not, this skill could be a credential-leak vector. Always check `.gitignore` first. +- **Per-project state.** `.projects/vault/vault.json` is per-project. Provisioning the same service in two different projects creates two separate resources — and two bills. +- **Billing happens on Stripe's side.** Tier prompts during `add`/`upgrade` are real charges; surface them to the user before confirming. +- **Provider availability changes.** The catalog grows; if a provider the user names isn't listed, `stripe projects catalog | grep ` first instead of failing the `add` call. +- **Credentials in vault are encrypted but `.env` is plaintext.** Standard `.env` hygiene applies — never commit it. +- **Removing a service does NOT always destroy the underlying resource.** Some providers leave a paused/dormant resource behind. Check the provider's own dashboard after `remove` for high-cost services (managed databases especially). + +## Verification + +``` +stripe projects --version && stripe projects list +``` + +Exit code 0 inside an initialized project means the plugin is healthy. diff --git a/website/sidebars.ts b/website/sidebars.ts index 9b45991a18..af12e6b883 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -150,6 +150,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent', + 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-kanban-codex-lane', 'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode', ], }, @@ -518,6 +519,17 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/optional/mlops/mlops-whisper', ], }, + { + type: 'category', + label: 'payments', + key: 'skills-optional-payments', + collapsed: true, + items: [ + 'user-guide/skills/optional/payments/payments-mpp-agent', + 'user-guide/skills/optional/payments/payments-stripe-link-cli', + 'user-guide/skills/optional/payments/payments-stripe-projects', + ], + }, { type: 'category', label: 'productivity', From 1cb75b7971a7f686eb4e4f39402c45ccdd395588 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 17:48:35 -0500 Subject: [PATCH 69/92] fix(desktop): coalesce interleaved reasoning/content stream parts Models that interleave their reasoning_content and content token streams (Kimi/DeepSeek/GLM-style routes) emit text -> reasoning -> text deltas within a single tool-bounded segment. Appending each delta as its own part shredded one sentence into "Let me" / Thinking / "verify the file", with a Thinking disclosure wedged mid-sentence. Coalesce streaming deltas into the most recent same-type part within the current segment (bounded by any non-streaming part, e.g. a tool call). The opposite streaming channel is transparent, so a reasoning burst between two content deltas no longer opens a fresh text part, while a real tool call still starts a new segment and preserves narration order. Data-layer only; the renderer already groups consecutive reasoning. --- apps/desktop/src/lib/chat-messages.test.ts | 47 ++++++++++++++ apps/desktop/src/lib/chat-messages.ts | 71 ++++++++++++++-------- 2 files changed, 94 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index f32310adfc..44a6915fed 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import type { ChatMessage, ChatMessagePart } from './chat-messages' import { appendAssistantTextPart, + appendReasoningPart, chatMessageText, preserveLocalAssistantErrors, renderMediaTags, @@ -175,6 +176,52 @@ describe('renderMediaTags', () => { }) }) +describe('interleaved reasoning/text coalescing', () => { + it('keeps narration contiguous when reasoning interrupts mid-sentence', () => { + // Models that interleave reasoning_content + content deltas emit + // text → reasoning → text within one tool-bounded segment. The two text + // fragments are really one sentence and must not be split by the + // "Thinking" block between them. + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ') + parts = appendReasoningPart(parts, 'checking the file...') + parts = appendAssistantTextPart(parts, 'verify the full file is correct:') + + expect(parts.map(p => p.type)).toEqual(['text', 'reasoning']) + expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:') + expect((parts[1] as { text: string }).text).toBe('checking the file...') + }) + + it('merges reasoning bursts that straddle a narration fragment', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ') + parts = appendAssistantTextPart(parts, 'Working on it.') + parts = appendReasoningPart(parts, 'second thought') + + expect(parts.map(p => p.type)).toEqual(['reasoning', 'text']) + expect((parts[0] as { text: string }).text).toBe('first thought second thought') + expect((parts[1] as { text: string }).text).toBe('Working on it.') + }) + + it('starts a fresh text part after a tool call (segment boundary)', () => { + let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.') + parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running') + parts = appendAssistantTextPart(parts, 'Now editing.') + + expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text']) + expect((parts[0] as { text: string }).text).toBe('Let me check.') + expect((parts[2] as { text: string }).text).toBe('Now editing.') + }) + + it('does not merge reasoning across a tool call', () => { + let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool') + parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running') + parts = appendReasoningPart(parts, 'after tool') + + expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning']) + expect((parts[0] as { text: string }).text).toBe('before tool') + expect((parts[2] as { text: string }).text).toBe('after tool') + }) +}) + describe('preserveLocalAssistantErrors', () => { it('preserves a local user+error pair when hydration omits the failed turn', () => { const nextMessages: ChatMessage[] = [ diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index e4b1f2fb16..2c69d642bc 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -178,52 +178,75 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText } -export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const next = [...parts] - const last = next.at(-1) +// When a model interleaves its `reasoning_content` and `content` token +// streams, deltas land as text → reasoning → text inside a single +// tool-bounded segment. Appending each delta as its own part shreds one +// sentence into "Let me" / Thinking / "verify the file" — the +// interleaved-thinking fragmentation users hit on Kimi/DeepSeek/GLM-style +// routes. To keep narration and thinking each contiguous, a streaming delta +// merges into the most recent same-type part *within the current segment*. +// +// A segment is bounded by any non-streaming part (a tool call, image, …): the +// opposite streaming channel (text <-> reasoning) is transparent, so a +// reasoning burst between two content deltas does NOT open a fresh text part, +// but a real tool call does. This collapses interleave noise without +// reordering narration across tool calls. +function segmentMergeIndex(parts: ChatMessagePart[], type: 'text' | 'reasoning'): number { + for (let i = parts.length - 1; i >= 0; i--) { + const partType = parts[i]?.type - if (last?.type === 'text') { - next[next.length - 1] = { ...last, text: `${last.text}${delta}` } + if (partType === type) { + return i + } - return next + // text <-> reasoning is the interleave we're collapsing; skip past it. + // Anything else (tool-call, file, image, …) closes the segment. + if (partType !== 'text' && partType !== 'reasoning') { + return -1 + } } - next.push(textPart(delta)) + return -1 +} + +function mergeTextInto(parts: ChatMessagePart[], index: number, delta: string): ChatMessagePart[] { + const next = [...parts] + const part = next[index] + next[index] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart return next } -export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const next = appendTextPart(parts, delta) - const last = next.at(-1) +export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const idx = segmentMergeIndex(parts, 'text') - if (last?.type === 'text') { - const current = last.text + return idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, textPart(delta)] +} + +export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const idx = segmentMergeIndex(parts, 'text') + const targetIndex = idx >= 0 ? idx : parts.length + const next = idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, textPart(delta)] + const target = next[targetIndex] + + if (target?.type === 'text') { + const current = target.text const deltaMayContainMedia = delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:') const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:') const nextText = needsMediaPass ? renderMediaTags(current) : current - next[next.length - 1] = nextText === current ? last : { ...last, text: nextText } + next[targetIndex] = nextText === current ? target : { ...target, text: nextText } } return next } export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const next = [...parts] - const last = next.at(-1) + const idx = segmentMergeIndex(parts, 'reasoning') - if (last?.type === 'reasoning') { - next[next.length - 1] = { ...last, text: `${last.text}${delta}` } - - return next - } - - next.push(reasoningPart(delta)) - - return next + return idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, reasoningPart(delta)] } export function hasToolPart(message: ChatMessage): boolean { From 37d717054ef6fc6bfd5097d6842f097a3419631b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 15 Jun 2026 18:13:52 -0500 Subject: [PATCH 70/92] refactor(desktop): unify stream-part coalescing into one helper Collapse segmentMergeIndex + mergeTextInto + the three append helpers into a single segment-aware appendStreamPart core plus a part-factory table. Same behavior, DRY. --- apps/desktop/src/lib/chat-messages.ts | 107 +++++++++++++------------- 1 file changed, 52 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 2c69d642bc..ef4eef6662 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -178,75 +178,72 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText } -// When a model interleaves its `reasoning_content` and `content` token -// streams, deltas land as text → reasoning → text inside a single -// tool-bounded segment. Appending each delta as its own part shreds one -// sentence into "Let me" / Thinking / "verify the file" — the -// interleaved-thinking fragmentation users hit on Kimi/DeepSeek/GLM-style -// routes. To keep narration and thinking each contiguous, a streaming delta -// merges into the most recent same-type part *within the current segment*. -// -// A segment is bounded by any non-streaming part (a tool call, image, …): the -// opposite streaming channel (text <-> reasoning) is transparent, so a -// reasoning burst between two content deltas does NOT open a fresh text part, -// but a real tool call does. This collapses interleave noise without -// reordering narration across tool calls. -function segmentMergeIndex(parts: ChatMessagePart[], type: 'text' | 'reasoning'): number { - for (let i = parts.length - 1; i >= 0; i--) { - const partType = parts[i]?.type +const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = { + reasoning: reasoningPart, + text: textPart +} - if (partType === type) { - return i +// Coalesce a streaming delta into the most recent same-type part within the +// current segment, where a segment is bounded by any non-streaming part (a +// tool call, image, …). The opposite streaming channel (text <-> reasoning) is +// transparent, so a reasoning burst between two content deltas can't shred one +// sentence into text / Thinking / text — the fragmentation models that +// interleave reasoning_content + content otherwise produce. Tool calls still +// open a fresh part, preserving narration order across steps. +function appendStreamPart( + parts: ChatMessagePart[], + type: 'reasoning' | 'text', + delta: string +): { index: number; parts: ChatMessagePart[] } { + const next = [...parts] + + for (let i = next.length - 1; i >= 0; i--) { + const part = next[i] + + if (part.type === type) { + next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart + + return { index: i, parts: next } } - // text <-> reasoning is the interleave we're collapsing; skip past it. - // Anything else (tool-call, file, image, …) closes the segment. - if (partType !== 'text' && partType !== 'reasoning') { - return -1 + if (part.type !== 'text' && part.type !== 'reasoning') { + break } } - return -1 -} + next.push(STREAM_PART[type](delta)) -function mergeTextInto(parts: ChatMessagePart[], index: number, delta: string): ChatMessagePart[] { - const next = [...parts] - const part = next[index] - next[index] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart - - return next + return { index: next.length - 1, parts: next } } export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const idx = segmentMergeIndex(parts, 'text') - - return idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, textPart(delta)] -} - -export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const idx = segmentMergeIndex(parts, 'text') - const targetIndex = idx >= 0 ? idx : parts.length - const next = idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, textPart(delta)] - const target = next[targetIndex] - - if (target?.type === 'text') { - const current = target.text - - const deltaMayContainMedia = - delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:') - - const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:') - const nextText = needsMediaPass ? renderMediaTags(current) : current - next[targetIndex] = nextText === current ? target : { ...target, text: nextText } - } - - return next + return appendStreamPart(parts, 'text', delta).parts } export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { - const idx = segmentMergeIndex(parts, 'reasoning') + return appendStreamPart(parts, 'reasoning', delta).parts +} - return idx >= 0 ? mergeTextInto(parts, idx, delta) : [...parts, reasoningPart(delta)] +export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] { + const { index, parts: next } = appendStreamPart(parts, 'text', delta) + const part = next[index] + + if (part?.type !== 'text') { + return next + } + + const mayContainMedia = + delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:') + + if (mayContainMedia || part.text.includes('MEDIA:')) { + const rendered = renderMediaTags(part.text) + + if (rendered !== part.text) { + next[index] = { ...part, text: rendered } + } + } + + return next } export function hasToolPart(message: ChatMessage): boolean { From 9d2ec8d35a2aa815f036a8d5bfa4698278210b16 Mon Sep 17 00:00:00 2001 From: Dominik <39583330+skyc1e@users.noreply.github.com> Date: Tue, 16 Jun 2026 01:52:00 +0200 Subject: [PATCH 71/92] Merge pull request #46244 from skyc1e/fix/desktop-explorer-refresh fix(desktop): keep file tree refresh clickable --- .../src/app/right-sidebar/index.test.tsx | 75 +++++++++++++++++++ apps/desktop/src/app/right-sidebar/index.tsx | 10 +-- 2 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/index.test.tsx diff --git a/apps/desktop/src/app/right-sidebar/index.test.tsx b/apps/desktop/src/app/right-sidebar/index.test.tsx new file mode 100644 index 0000000000..07a0fbb043 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/index.test.tsx @@ -0,0 +1,75 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { HermesReadDirResult } from '@/global' +import { $connection, setCurrentCwd } from '@/store/session' + +import { resetProjectTreeState } from './files/use-project-tree' + +import { RightSidebarPane } from './index' + +const readDir = vi.fn<(path: string) => Promise>() +const selectPaths = vi.fn() + +function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult { + return { entries } +} + +function installBridge() { + ;( + window as unknown as { + hermesDesktop: { + readDir: typeof readDir + selectPaths: typeof selectPaths + } + } + ).hermesDesktop = { readDir, selectPaths } +} + +describe('RightSidebarPane', () => { + beforeEach(() => { + $connection.set(null) + resetProjectTreeState() + setCurrentCwd('/repo') + readDir.mockReset() + selectPaths.mockReset() + readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }])) + selectPaths.mockResolvedValue(['/repo-next']) + installBridge() + }) + + afterEach(() => { + cleanup() + $connection.set(null) + setCurrentCwd('') + resetProjectTreeState() + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('refreshes the current tree without opening the folder picker', async () => { + const onChangeCwd = vi.fn() + + render() + + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false)) + + readDir.mockClear() + + fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' })) + + await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo')) + expect(selectPaths).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Open folder' })) + + await waitFor(() => + expect(selectPaths).toHaveBeenCalledWith({ + defaultPath: '/repo', + directories: true, + multiple: false, + title: 'Change working directory' + }) + ) + await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next')) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 8a77dbc984..21085912fc 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps { onRefresh: () => void } -// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all -// stays visible while any folder is expanded. +// Sidebar palette + hover-reveal: header actions stay reachable while moving +// from the project label to the action buttons. const HEADER_ACTION_CLASS = 'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring' -const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100` +const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100` function FilesystemTab({ canCollapse, @@ -158,7 +158,7 @@ function FilesystemTab({ return (
-
+
- {collapsed && hovered && liRef.current && ( - + {collapsed && hovered && tooltipAnchor && ( + )} ); @@ -1049,18 +1068,25 @@ function SidebarIconWithTooltip({ label, tooltipWarmRef, }: SidebarIconWithTooltipProps) { - const ref = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); + const showTooltip = (event: MouseEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} > {children} @@ -1071,8 +1097,8 @@ function SidebarIconWithTooltip({ /> )} - {collapsed && hovered && ref.current && ( - + {collapsed && hovered && tooltipAnchor && ( + )}
); @@ -1080,8 +1106,8 @@ function SidebarIconWithTooltip({ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) { const { t } = useI18n(); - const ref = useRef(null); const [hovered, setHovered] = useState(false); + const [tooltipAnchor, setTooltipAnchor] = useState(null); const toneToColor: Record = { "text-success": "bg-success", @@ -1101,10 +1127,17 @@ function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) { color = toneToColor[gw.tone] ?? "bg-muted-foreground"; label = `${t.status.gateway} ${gw.label}`; } + const showTooltip = (event: MouseEvent | FocusEvent) => { + setHovered(true); + setTooltipAnchor(event.currentTarget); + }; + const hideTooltip = () => { + setHovered(false); + setTooltipAnchor(null); + }; return (
setHovered(true) : undefined} - onMouseLeave={collapsed ? () => setHovered(false) : undefined} - onFocus={collapsed ? () => setHovered(true) : undefined} - onBlur={collapsed ? () => setHovered(false) : undefined} + onMouseEnter={collapsed ? showTooltip : undefined} + onMouseLeave={collapsed ? hideTooltip : undefined} + onFocus={collapsed ? showTooltip : undefined} + onBlur={collapsed ? hideTooltip : undefined} > - {hovered && ref.current && ( - + {hovered && tooltipAnchor && ( + )}
); @@ -1133,11 +1166,16 @@ function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) { const rect = anchor.getBoundingClientRect(); const sidebar = document.getElementById("app-sidebar"); const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right; - - const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false; + const [isWarm, setIsWarm] = useState(false); useEffect(() => { - if (warmRef) warmRef.current = Date.now(); + if (!warmRef) { + setIsWarm(false); + return; + } + const now = Date.now(); + setIsWarm(now - warmRef.current < 300); + warmRef.current = now; return () => { if (warmRef) warmRef.current = Date.now(); }; diff --git a/web/src/contexts/SystemActions.tsx b/web/src/contexts/SystemActions.tsx index 976bf4c32a..2dd05232c0 100644 --- a/web/src/contexts/SystemActions.tsx +++ b/web/src/contexts/SystemActions.tsx @@ -74,19 +74,17 @@ export function SystemActionsProvider({ setActiveAction(action); } else { const resp = await api.updateHermes(); - // In a Docker install the image is immutable, so `hermes update` - // can't apply — the endpoint returns 200 with a structured - // {ok:false, error:"docker_update_unsupported", message, update_command} - // envelope instead of spawning the action (see #34347 / #36263). - // Surface that guidance to the user rather than starting the poll, - // which would otherwise report a generic "failed (exit 1)". - if (!resp.ok && resp.error === "docker_update_unsupported") { + // Some installs cannot apply updates from inside the dashboard. The + // endpoint returns a structured {ok:false, message, update_command} + // envelope instead of spawning the action; surface that guidance + // rather than polling a synthetic failed action. + if (!resp.ok) { const cmd = resp.update_command ? ` ${resp.update_command}` : ""; setToast({ type: "success", message: (resp.message ?? - "Updates don't apply inside Docker — re-pull the image instead.") + + "Updates don't apply from this dashboard.") + cmd, }); return; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index fab64b64c8..2a49d5a9f7 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1570,6 +1570,9 @@ export interface StatusResponse { * Empty in loopback mode; empty + ``auth_required=true`` is a * fail-closed state (the dashboard will refuse to bind). */ auth_providers?: string[]; + /** False when the dashboard is running in a hosted/managed layout where + * updates are handled by the outer launcher instead of ``hermes update``. */ + can_update_hermes?: boolean; config_path: string; config_version: number; env_path: string; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index 6197b456e5..f22bb55321 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -386,6 +386,7 @@ export default function SystemPage() { // ── Update check / apply ─────────────────────────────────────────── const checkForUpdate = useCallback( async (force = false) => { + if (status?.can_update_hermes === false) return; setCheckingUpdate(true); try { const info = await api.checkHermesUpdate(force); @@ -410,20 +411,27 @@ export default function SystemPage() { setCheckingUpdate(false); } }, - [showToast], + [showToast, status?.can_update_hermes], ); // Auto-check (cached) runs inside loadAll on mount; this is the // user-triggered forced re-check from the "Check for updates" button. const applyUpdate = async () => { setUpdateConfirmOpen(false); + if (status?.can_update_hermes === false) { + showToast( + "Hermes updates are managed by the hosted agent service.", + "success", + ); + return; + } try { const resp = await api.updateHermes(); - if (!resp.ok && resp.error === "docker_update_unsupported") { + if (!resp.ok) { showToast( resp.message ?? - "Updates don't apply inside Docker — re-pull the image instead.", - "error", + "Updates don't apply from this dashboard.", + "success", ); return; } @@ -503,6 +511,7 @@ export default function SystemPage() { } const gatewayRunning = status?.gateway_running; + const canUpdateHermes = status?.can_update_hermes !== false; const validEvents = hooks?.valid_events?.length ? hooks.valid_events : HOOK_EVENTS_FALLBACK; @@ -512,7 +521,7 @@ export default function SystemPage() { setUpdateConfirmOpen(false)} onConfirm={() => void applyUpdate()} title="Update Hermes?" @@ -691,7 +700,8 @@ export default function SystemPage() {
Hermes
v{stats?.hermes_version} - {updateInfo && + {canUpdateHermes && + updateInfo && (updateInfo.update_available ? ( {updateInfo.behind && updateInfo.behind > 0 @@ -751,45 +761,47 @@ export default function SystemPage() { CPU / memory / disk metrics.

)} -
- - {updateInfo?.update_available && updateInfo.can_apply && ( + {canUpdateHermes && ( +
- )} - {updateInfo && - !updateInfo.can_apply && - updateInfo.update_available && ( + {updateInfo?.update_available && updateInfo.can_apply && ( + + )} + {updateInfo && + !updateInfo.can_apply && + updateInfo.update_available && ( + + Update with{" "} + {updateInfo.update_command} + + )} + {updateInfo?.message && !updateInfo.update_available && ( - Update with{" "} - {updateInfo.update_command} + {updateInfo.message} )} - {updateInfo?.message && !updateInfo.update_available && ( - - {updateInfo.message} - - )} -
+
+ )} From b1d6a578832dadc9ef3ba1a591e5ea8bebd7d568 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Tue, 16 Jun 2026 12:17:53 +1000 Subject: [PATCH 78/92] Detect containerized dashboard update management --- hermes_cli/web_server.py | 47 ++++++++++++------- .../test_dashboard_admin_endpoints.py | 6 +-- tests/hermes_cli/test_web_server.py | 19 ++++++-- web/src/pages/SystemPage.tsx | 2 +- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e38ab1cb61..007e598102 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1224,15 +1224,24 @@ def _default_hermes_root_is_opt_data() -> bool: return root == _HOSTED_MANAGED_FILES_ROOT -def _dashboard_hosted_agent_mode() -> bool: - """Return true for the hosted/container dashboard layout. +def _dashboard_local_update_managed_externally() -> bool: + """Return true when the dashboard should not offer ``hermes update``. - Hosted agent dashboards run with the Hermes root at ``/opt/data``. This is - the same signal the Files page uses to lock browsing to the managed data - directory, and it keeps local remote-auth dashboards from being mistaken for - hosted service instances. + Hosted agent dashboards run with the Hermes root at ``/opt/data``. Generic + containerized dashboards may not use that exact root, but their lifecycle is + still owned by the outer launcher/image, not by an in-browser local update + action. Keep this dashboard capability separate from install-method + detection: manual git/pip installs inside containers can still behave like + their actual install method in the CLI. """ - return _default_hermes_root_is_opt_data() + if _default_hermes_root_is_opt_data(): + return True + try: + from hermes_constants import is_container + + return is_container() + except Exception: + return False def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: @@ -1665,7 +1674,7 @@ async def get_status(): "release_date": __release_date__, "config_version": current_ver, "latest_config_version": latest_ver, - "can_update_hermes": not _dashboard_hosted_agent_mode(), + "can_update_hermes": not _dashboard_local_update_managed_externally(), "gateway_running": gateway_running, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, @@ -2177,19 +2186,20 @@ async def restart_gateway(): @app.post("/api/hermes/update") async def update_hermes(): """Kick off ``hermes update`` in the background.""" - if _dashboard_hosted_agent_mode(): + if _dashboard_local_update_managed_externally(): message = ( - "Hermes updates are managed by the hosted agent service for this " - "dashboard. The built-in local updater is disabled here." + "Hermes updates are managed outside this dashboard for hosted or " + "containerized environments. The built-in local updater is " + "disabled here." ) _record_completed_action("hermes-update", message, exit_code=1) return { "ok": False, "pid": None, "name": "hermes-update", - "error": "hosted_update_managed", + "error": "dashboard_update_managed_externally", "message": message, - "update_command": "managed by hosted agent service", + "update_command": "managed outside dashboard", } install_method = detect_install_method(PROJECT_ROOT) @@ -2291,15 +2301,18 @@ async def check_hermes_update(force: bool = False): desktop's remote update overlay renders this as "what's changed". Additive: existing consumers ignore it. """ - if _dashboard_hosted_agent_mode(): + if _dashboard_local_update_managed_externally(): return { - "install_method": "hosted", + "install_method": "managed-runtime", "current_version": __version__, "behind": None, "update_available": False, "can_apply": False, - "update_command": "managed by hosted agent service", - "message": "Hermes updates are managed by the hosted agent service.", + "update_command": "managed outside dashboard", + "message": ( + "Hermes updates are managed outside this dashboard for hosted " + "or containerized environments." + ), } install_method = detect_install_method(PROJECT_ROOT) diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 933615e897..b87489e7f3 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -775,7 +775,7 @@ class TestUpdateCheckEndpoint: def test_hosted_dashboard_is_not_applyable(self, monkeypatch): import hermes_cli.web_server as ws - monkeypatch.setattr(ws, "_dashboard_hosted_agent_mode", lambda: True) + monkeypatch.setattr(ws, "_dashboard_local_update_managed_externally", lambda: True) monkeypatch.setattr( ws, "detect_install_method", @@ -785,11 +785,11 @@ class TestUpdateCheckEndpoint: ) body = self.client.get("/api/hermes/update/check").json() - assert body["install_method"] == "hosted" + assert body["install_method"] == "managed-runtime" assert body["can_apply"] is False assert body["update_available"] is False assert body["behind"] is None - assert "hosted agent service" in body["message"] + assert "managed outside this dashboard" in body["message"] def test_check_failure_is_soft(self, monkeypatch): import hermes_cli.web_server as ws diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1ad0277dbe..2bc3138d4f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -250,12 +250,21 @@ class TestWebServerEndpoints: def test_get_status_hides_update_capability_in_hosted_mode(self, monkeypatch): import hermes_cli.web_server as web_server - monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True) + monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True) resp = self.client.get("/api/status") assert resp.status_code == 200 assert resp.json()["can_update_hermes"] is False + def test_dashboard_update_capability_detects_generic_container(self, monkeypatch): + import hermes_constants + import hermes_cli.web_server as web_server + + monkeypatch.setattr(web_server, "_default_hermes_root_is_opt_data", lambda: False) + monkeypatch.setattr(hermes_constants, "is_container", lambda: True) + + assert web_server._dashboard_local_update_managed_externally() is True + # ── GET /api/media (remote image display) ─────────────────────────── def test_get_media_serves_image_in_root(self): @@ -938,7 +947,7 @@ class TestWebServerEndpoints: detected = True raise AssertionError("hosted update guard should not detect install method") - monkeypatch.setattr(web_server, "_dashboard_hosted_agent_mode", lambda: True) + monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True) monkeypatch.setattr(web_server, "detect_install_method", fail_detect) monkeypatch.setattr(web_server, "_spawn_hermes_action", fail_spawn) web_server._ACTION_PROCS.pop("hermes-update", None) @@ -951,8 +960,8 @@ class TestWebServerEndpoints: assert data["ok"] is False assert data["name"] == "hermes-update" assert data["pid"] is None - assert data["error"] == "hosted_update_managed" - assert "hosted agent service" in data["message"] + assert data["error"] == "dashboard_update_managed_externally" + assert "managed outside this dashboard" in data["message"] assert spawned is False assert detected is False @@ -962,7 +971,7 @@ class TestWebServerEndpoints: assert status_data["running"] is False assert status_data["exit_code"] == 1 assert status_data["pid"] is None - assert any("hosted agent service" in line for line in status_data["lines"]) + assert any("managed outside this dashboard" in line for line in status_data["lines"]) def test_update_hermes_spawns_on_non_docker_install(self, monkeypatch): import hermes_cli.web_server as web_server diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index f22bb55321..24cb68894b 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -420,7 +420,7 @@ export default function SystemPage() { setUpdateConfirmOpen(false); if (status?.can_update_hermes === false) { showToast( - "Hermes updates are managed by the hosted agent service.", + "Hermes updates are managed outside this dashboard.", "success", ); return; From 7cd71de1f45b060b1404b2f0c5bf4cb4305dd716 Mon Sep 17 00:00:00 2001 From: Shannon Sands Date: Tue, 16 Jun 2026 12:57:28 +1000 Subject: [PATCH 79/92] Simplify dashboard update detection to containers --- hermes_cli/web_server.py | 18 +++++++----------- .../test_dashboard_admin_endpoints.py | 4 ++-- tests/hermes_cli/test_web_server.py | 9 ++++----- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 007e598102..7cd3a7eaa0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1227,15 +1227,11 @@ def _default_hermes_root_is_opt_data() -> bool: def _dashboard_local_update_managed_externally() -> bool: """Return true when the dashboard should not offer ``hermes update``. - Hosted agent dashboards run with the Hermes root at ``/opt/data``. Generic - containerized dashboards may not use that exact root, but their lifecycle is - still owned by the outer launcher/image, not by an in-browser local update - action. Keep this dashboard capability separate from install-method - detection: manual git/pip installs inside containers can still behave like - their actual install method in the CLI. + Containerized dashboards are updated by the outer launcher/image, not by an + in-browser local update action. Keep this dashboard capability separate + from install-method detection: manual git/pip installs inside containers can + still behave like their actual install method in the CLI. """ - if _default_hermes_root_is_opt_data(): - return True try: from hermes_constants import is_container @@ -2188,7 +2184,7 @@ async def update_hermes(): """Kick off ``hermes update`` in the background.""" if _dashboard_local_update_managed_externally(): message = ( - "Hermes updates are managed outside this dashboard for hosted or " + "Hermes updates are managed outside this dashboard in " "containerized environments. The built-in local updater is " "disabled here." ) @@ -2310,8 +2306,8 @@ async def check_hermes_update(force: bool = False): "can_apply": False, "update_command": "managed outside dashboard", "message": ( - "Hermes updates are managed outside this dashboard for hosted " - "or containerized environments." + "Hermes updates are managed outside this dashboard in " + "containerized environments." ), } diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index b87489e7f3..3eb2ca37d2 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -772,7 +772,7 @@ class TestUpdateCheckEndpoint: assert body["message"] assert body["behind"] is None - def test_hosted_dashboard_is_not_applyable(self, monkeypatch): + def test_managed_runtime_dashboard_is_not_applyable(self, monkeypatch): import hermes_cli.web_server as ws monkeypatch.setattr(ws, "_dashboard_local_update_managed_externally", lambda: True) @@ -780,7 +780,7 @@ class TestUpdateCheckEndpoint: ws, "detect_install_method", lambda *a, **k: pytest.fail( - "hosted update check should not probe install method" + "managed runtime update check should not probe install method" ), ) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 2bc3138d4f..8f6842b6b5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -247,7 +247,7 @@ class TestWebServerEndpoints: assert "active_sessions" in data assert data["can_update_hermes"] is True - def test_get_status_hides_update_capability_in_hosted_mode(self, monkeypatch): + def test_get_status_hides_update_capability_in_managed_runtime(self, monkeypatch): import hermes_cli.web_server as web_server monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True) @@ -260,7 +260,6 @@ class TestWebServerEndpoints: import hermes_constants import hermes_cli.web_server as web_server - monkeypatch.setattr(web_server, "_default_hermes_root_is_opt_data", lambda: False) monkeypatch.setattr(hermes_constants, "is_container", lambda: True) assert web_server._dashboard_local_update_managed_externally() is True @@ -931,7 +930,7 @@ class TestWebServerEndpoints: assert status_data["pid"] is None assert any("docker pull nousresearch/hermes-agent:latest" in line for line in status_data["lines"]) - def test_update_hermes_returns_hosted_guidance_without_spawning(self, monkeypatch): + def test_update_hermes_returns_managed_runtime_guidance_without_spawning(self, monkeypatch): import hermes_cli.web_server as web_server spawned = False @@ -940,12 +939,12 @@ class TestWebServerEndpoints: def fail_spawn(*_args, **_kwargs): nonlocal spawned spawned = True - raise AssertionError("hosted update guard should not spawn hermes update") + raise AssertionError("managed runtime update guard should not spawn hermes update") def fail_detect(*_args, **_kwargs): nonlocal detected detected = True - raise AssertionError("hosted update guard should not detect install method") + raise AssertionError("managed runtime update guard should not detect install method") monkeypatch.setattr(web_server, "_dashboard_local_update_managed_externally", lambda: True) monkeypatch.setattr(web_server, "detect_install_method", fail_detect) From 0441b7f19feb9f1fdd9aac2af8cf022f1b50b174 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:24:55 -0600 Subject: [PATCH 80/92] fix(desktop): route global remote profile REST calls (#47011) * fix(desktop): route global remote profile REST calls * fix(dashboard): scope oauth provider routes by profile * test(tui): isolate notification poller queue --- apps/desktop/electron/connection-config.cjs | 34 +++ .../electron/connection-config.test.cjs | 67 +++++ apps/desktop/electron/main.cjs | 10 +- hermes_cli/auth.py | 29 +- hermes_cli/web_server.py | 278 +++++++++++------- .../hermes_cli/test_nous_auth_status_cache.py | 41 ++- tests/hermes_cli/test_web_oauth_dispatch.py | 133 +++++++++ tests/test_tui_gateway_server.py | 12 +- 8 files changed, 478 insertions(+), 126 deletions(-) diff --git a/apps/desktop/electron/connection-config.cjs b/apps/desktop/electron/connection-config.cjs index 4595ca043c..f9eaaa65e9 100644 --- a/apps/desktop/electron/connection-config.cjs +++ b/apps/desktop/electron/connection-config.cjs @@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) { return { url, authMode: normAuthMode(entry.authMode), token: entry.token } } +/** + * In global-remote mode one backend serves every Desktop profile, so REST calls + * that are scoped by renderer-side `request.profile` must carry that scope as a + * query parameter. Local pooled backends and per-profile remote overrides do not + * need this: they already run against a backend scoped to the target profile. + */ +function pathWithGlobalRemoteProfile(path, profile, opts = {}) { + const scopedProfile = connectionScopeKey(profile) + if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) { + return path + } + + const rawPath = String(path || '') + if (!rawPath) { + return path + } + + let parsed + try { + parsed = new URL(rawPath, 'http://hermes.local') + } catch { + return path + } + + if (parsed.searchParams.has('profile')) { + return path + } + + parsed.searchParams.set('profile', scopedProfile) + + return `${parsed.pathname}${parsed.search}${parsed.hash}` +} + function tokenPreview(value) { const raw = String(value || '') @@ -247,6 +280,7 @@ module.exports = { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, diff --git a/apps/desktop/electron/connection-config.test.cjs b/apps/desktop/electron/connection-config.test.cjs index 7e7332ca33..1c7330e78d 100644 --- a/apps/desktop/electron/connection-config.test.cjs +++ b/apps/desktop/electron/connection-config.test.cjs @@ -24,6 +24,7 @@ const { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, @@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => { assert.equal(profileRemoteOverride(null, 'coder'), null) }) +// --- pathWithGlobalRemoteProfile --- + +test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info?profile=iris' + ) +}) + +test('pathWithGlobalRemoteProfile preserves existing query params', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/options?force=1&profile=iris' + ) +}) + +test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info?profile=default' + ) +}) + +test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: false, + profileRemoteOverride: false + }), + '/api/model/info' + ) + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', 'iris', { + globalRemote: true, + profileRemoteOverride: true + }), + '/api/model/info' + ) +}) + +test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => { + assert.equal( + pathWithGlobalRemoteProfile('/api/model/info', '', { + globalRemote: true, + profileRemoteOverride: false + }), + '/api/model/info' + ) + assert.equal( + pathWithGlobalRemoteProfile('', 'iris', { + globalRemote: true, + profileRemoteOverride: false + }), + '' + ) +}) + // --- normalizeRemoteBaseUrl --- test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => { diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 101cd0801f..19096c6135 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -63,6 +63,7 @@ const { cookiesHaveLiveSession, normAuthMode, normalizeRemoteBaseUrl, + pathWithGlobalRemoteProfile, profileRemoteOverride, resolveAuthMode, resolveTestWsUrl, @@ -5612,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => { await prepareProfileDeleteRequest(request) - const connection = await ensureBackend(request?.profile) + const profile = request?.profile + const connection = await ensureBackend(profile) const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS) - const url = `${connection.baseUrl}${request.path}` + const requestPath = pathWithGlobalRemoteProfile(request.path, profile, { + globalRemote: globalRemoteActive(), + profileRemoteOverride: profileHasRemoteOverride(profile) + }) + const url = `${connection.baseUrl}${requestPath}` // OAuth gateways authenticate REST via the HttpOnly session cookie held in // the OAuth partition — route through Electron's net stack bound to that // session so the cookie attaches automatically. Token/local modes keep using diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index f7857b4854..452723a3df 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5763,18 +5763,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]: # subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms" # was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning # single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json -# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call. +# path + mtime so that profile switches do not share a process memo and +# `hermes auth login/logout/add/remove` invalidate naturally on the next call. _NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds -_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None +_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None -def _auth_file_mtime() -> Optional[float]: +def _auth_file_cache_key() -> Tuple[str, Optional[float]]: + auth_file = _auth_file_path() try: - return _auth_file_path().stat().st_mtime - except FileNotFoundError: - return None + auth_file_key = str(auth_file.resolve(strict=False)) except Exception: - return None + auth_file_key = str(auth_file) + try: + return auth_file_key, auth_file.stat().st_mtime + except FileNotFoundError: + return auth_file_key, None + except Exception: + return auth_file_key, None def invalidate_nous_auth_status_cache() -> None: @@ -5806,18 +5812,19 @@ def get_nous_auth_status() -> Dict[str, Any]: """ global _nous_auth_status_cache now = time.monotonic() - mtime = _auth_file_mtime() + auth_file_key, mtime = _auth_file_cache_key() cached = _nous_auth_status_cache if cached is not None: - cached_at, cached_mtime, cached_status = cached + cached_at, cached_auth_file_key, cached_mtime, cached_status = cached if ( - cached_mtime == mtime + cached_auth_file_key == auth_file_key + and cached_mtime == mtime and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL ): return dict(cached_status) status = _compute_nous_auth_status() - _nous_auth_status_cache = (now, mtime, dict(status)) + _nous_auth_status_cache = (now, auth_file_key, mtime, dict(status)) return status diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7cd3a7eaa0..c434fb6751 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5191,7 +5191,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, @app.get("/api/providers/oauth") -async def list_oauth_providers(): +async def list_oauth_providers(profile: Optional[str] = None): """Enumerate every OAuth-capable LLM provider with current status. Response shape (per provider): @@ -5208,83 +5208,89 @@ async def list_oauth_providers(): expires_at ISO timestamp string or null has_refresh_token bool """ - providers = [] - for p in _OAUTH_PROVIDER_CATALOG: - status = _resolve_provider_status(p["id"], p.get("status_fn")) - disconnect_hint = _oauth_provider_disconnect_hint(p, status) - providers.append({ - "id": p["id"], - "name": p["name"], - "flow": p["flow"], - "cli_command": p["cli_command"], - "docs_url": p["docs_url"], - "disconnect_hint": disconnect_hint, - "disconnectable": disconnect_hint is None, - "status": status, - }) - return {"providers": providers} + with _profile_scope(profile): + providers = [] + for p in _OAUTH_PROVIDER_CATALOG: + status = _resolve_provider_status(p["id"], p.get("status_fn")) + disconnect_hint = _oauth_provider_disconnect_hint(p, status) + providers.append({ + "id": p["id"], + "name": p["name"], + "flow": p["flow"], + "cli_command": p["cli_command"], + "docs_url": p["docs_url"], + "disconnect_hint": disconnect_hint, + "disconnectable": disconnect_hint is None, + "status": status, + }) + return {"providers": providers} @app.delete("/api/providers/oauth/{provider_id}") -async def disconnect_oauth_provider(provider_id: str, request: Request): +async def disconnect_oauth_provider( + provider_id: str, + request: Request, + profile: Optional[str] = None, +): """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" _require_token(request) - catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG} - provider = catalog_by_id.get(provider_id) - if provider is None: - raise HTTPException( - status_code=400, - detail=f"Unknown provider: {provider_id}. " - f"Available: {', '.join(sorted(catalog_by_id))}", - ) + with _profile_scope(profile): + catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG} + provider = catalog_by_id.get(provider_id) + if provider is None: + raise HTTPException( + status_code=400, + detail=f"Unknown provider: {provider_id}. " + f"Available: {', '.join(sorted(catalog_by_id))}", + ) - disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) - if disconnect_hint: - raise HTTPException( - status_code=400, - detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", - ) + disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) + if disconnect_hint: + raise HTTPException( + status_code=400, + detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", + ) - status = _resolve_provider_status(provider_id, provider.get("status_fn")) - disconnect_hint = _oauth_provider_disconnect_hint(provider, status) - if disconnect_hint: - raise HTTPException( - status_code=400, - detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", - ) + status = _resolve_provider_status(provider_id, provider.get("status_fn")) + disconnect_hint = _oauth_provider_disconnect_hint(provider, status) + if disconnect_hint: + raise HTTPException( + status_code=400, + detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", + ) + + # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. + # The separate claude-code catalog row is external/read-only and rejected + # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. + if provider_id == "anthropic": + cleared = False + try: + from agent.anthropic_adapter import _HERMES_OAUTH_FILE + if _HERMES_OAUTH_FILE.exists(): + _HERMES_OAUTH_FILE.unlink() + cleared = True + except Exception: + pass + # Also clear the credential pool entry if present. + try: + from hermes_cli.auth import clear_provider_auth + cleared = clear_provider_auth("anthropic") or cleared + except Exception: + pass + _log.info("oauth/disconnect: %s", provider_id) + return {"ok": bool(cleared), "provider": provider_id} - # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. - # The separate claude-code catalog row is external/read-only and rejected - # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. - if provider_id == "anthropic": - cleared = False try: - from agent.anthropic_adapter import _HERMES_OAUTH_FILE - if _HERMES_OAUTH_FILE.exists(): - _HERMES_OAUTH_FILE.unlink() - cleared = True - except Exception: - pass - # Also clear the credential pool entry if present. - try: - from hermes_cli.auth import clear_provider_auth - cleared = clear_provider_auth("anthropic") or cleared - except Exception: - pass - _log.info("oauth/disconnect: %s", provider_id) - return {"ok": bool(cleared), "provider": provider_id} - - try: - from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache - cleared = clear_provider_auth(provider_id) - if provider_id == "nous": - invalidate_nous_auth_status_cache() - _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) - return {"ok": bool(cleared), "provider": provider_id} - except Exception as e: - _log.exception("disconnect %s failed", provider_id) - raise HTTPException(status_code=500, detail=str(e)) + from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache + cleared = clear_provider_auth(provider_id) + if provider_id == "nous": + invalidate_nous_auth_status_cache() + _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) + return {"ok": bool(cleared), "provider": provider_id} + except Exception as e: + _log.exception("disconnect %s failed", provider_id) + raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- @@ -5366,13 +5372,32 @@ def _gc_oauth_sessions() -> None: _oauth_sessions.pop(sid, None) -def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]: +def _oauth_profile_name(profile: Optional[str]) -> Optional[str]: + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + return None + return requested + + +def _validate_oauth_profile(profile: Optional[str]) -> None: + profile_name = _oauth_profile_name(profile) + if profile_name: + _resolve_profile_dir(profile_name) + + +def _new_oauth_session( + provider_id: str, + flow: str, + profile: Optional[str] = None, +) -> tuple[str, Dict[str, Any]]: """Create + register a new OAuth session, return (session_id, session_dict).""" sid = secrets.token_urlsafe(16) + profile_name = _oauth_profile_name(profile) sess = { "session_id": sid, "provider": provider_id, "flow": flow, + "profile": profile_name, "created_at": time.time(), "status": "pending", # pending | approved | denied | expired | error "error_message": None, @@ -5382,6 +5407,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any] return sid, sess +def _oauth_session_profile( + session_id: str, + fallback: Optional[str] = None, +) -> Optional[str]: + """Return the profile that owns an OAuth session, if one was provided.""" + with _oauth_sessions_lock: + sess = _oauth_sessions.get(session_id) + profile = sess.get("profile") if sess else None + return profile or _oauth_profile_name(fallback) + + def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: """Persist Anthropic PKCE creds to both Hermes file AND credential pool. @@ -5449,12 +5485,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a _log.warning("anthropic pool add (dashboard) failed: %s", e) -def _start_anthropic_pkce() -> Dict[str, Any]: +def _start_anthropic_pkce(profile: Optional[str] = None) -> Dict[str, Any]: """Begin PKCE flow. Returns the auth URL the UI should open.""" if not _ANTHROPIC_OAUTH_AVAILABLE: raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)") verifier, challenge = _generate_pkce_pair() - sid, sess = _new_oauth_session("anthropic", "pkce") + sid, sess = _new_oauth_session("anthropic", "pkce", profile=profile) sess["verifier"] = verifier sess["state"] = verifier # Anthropic round-trips verifier as state params = { @@ -5476,7 +5512,11 @@ def _start_anthropic_pkce() -> Dict[str, Any]: } -def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: +def _submit_anthropic_pkce( + session_id: str, + code_input: str, + profile: Optional[str] = None, +) -> Dict[str, Any]: """Exchange authorization code for tokens. Persists on success.""" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) @@ -5530,7 +5570,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: expires_at_ms = int(time.time() * 1000) + (expires_in * 1000) try: - _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) + with _profile_scope(_oauth_session_profile(session_id, profile)): + _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) except Exception as e: with _oauth_sessions_lock: sess["status"] = "error" @@ -5542,7 +5583,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: return {"ok": True, "status": "approved"} -async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: +async def _start_device_code_flow( + provider_id: str, + profile: Optional[str] = None, +) -> Dict[str, Any]: """Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax). Calls the provider's device-auth endpoint via the existing CLI helpers, @@ -5582,7 +5626,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( None, _do_nous_device_request ) - sid, sess = _new_oauth_session("nous", "device_code") + sid, sess = _new_oauth_session("nous", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) @@ -5603,7 +5647,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: if provider_id == "openai-codex": # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. - sid, _ = _new_oauth_session("openai-codex", "device_code") + sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile) # Use the helper but in a thread because it polls inline. # We can't extract just the start step without refactoring auth.py, # so we run the full helper in a worker and proxy the user_code + @@ -5670,7 +5714,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) - sid, sess = _new_oauth_session("minimax-oauth", "device_code") + sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile) # The CLI flow names this `interval_ms` because MiniMax's # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). @@ -5724,7 +5768,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: _XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0 -def _start_xai_loopback_flow() -> Dict[str, Any]: +def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]: """Begin the xAI loopback PKCE flow. Binds the local callback server, builds the authorize URL, and spawns a @@ -5763,7 +5807,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]: pass raise - sid, sess = _new_oauth_session("xai-oauth", "loopback") + sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile) sess["server"] = server sess["thread"] = thread sess["callback_result"] = callback_result @@ -5866,13 +5910,14 @@ def _xai_loopback_worker(session_id: str) -> None: } if _cancelled(): return - hauth._save_xai_oauth_tokens( - tokens, - discovery=sess.get("discovery"), - redirect_uri=sess["redirect_uri"], - last_refresh=last_refresh, - ) - _add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh) + with _profile_scope(_oauth_session_profile(session_id)): + hauth._save_xai_oauth_tokens( + tokens, + discovery=sess.get("discovery"), + redirect_uri=sess["redirect_uri"], + last_refresh=last_refresh, + ) + _add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh) except Exception as exc: _fail(f"xAI token exchange failed: {exc}") return @@ -5975,13 +6020,14 @@ def _nous_poller(session_id: str) -> None: ), "expires_in": token_ttl, } - full_state = refresh_nous_oauth_from_state( - auth_state, - timeout_seconds=15.0, - force_refresh=False, - ) - from hermes_cli.auth import persist_nous_credentials - persist_nous_credentials(full_state) + with _profile_scope(_oauth_session_profile(session_id)): + full_state = refresh_nous_oauth_from_state( + auth_state, + timeout_seconds=15.0, + force_refresh=False, + ) + from hermes_cli.auth import persist_nous_credentials + persist_nous_credentials(full_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: nous login completed (session=%s)", session_id) @@ -6064,7 +6110,8 @@ def _minimax_poller(session_id: str) -> None: ).isoformat(), "expires_in": expires_in_s, } - _minimax_save_auth_state(auth_state) + with _profile_scope(_oauth_session_profile(session_id)): + _minimax_save_auth_state(auth_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: minimax login completed (session=%s)", session_id) @@ -6177,10 +6224,11 @@ def _codex_full_login_worker(session_id: str) -> None: from hermes_cli.auth import _save_codex_tokens - _save_codex_tokens({ - "access_token": access_token, - "refresh_token": refresh_token, - }) + with _profile_scope(_oauth_session_profile(session_id)): + _save_codex_tokens({ + "access_token": access_token, + "refresh_token": refresh_token, + }) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) @@ -6194,10 +6242,15 @@ def _codex_full_login_worker(session_id: str) -> None: @app.post("/api/providers/oauth/{provider_id}/start") -async def start_oauth_login(provider_id: str, request: Request): +async def start_oauth_login( + provider_id: str, + request: Request, + profile: Optional[str] = None, +): """Initiate an OAuth login flow. Token-protected.""" _require_token(request) _gc_oauth_sessions() + _validate_oauth_profile(profile) valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid: raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") @@ -6215,12 +6268,12 @@ async def start_oauth_login(provider_id: str, request: Request): # change for MiniMax). New PKCE providers must add their own # start function and an explicit branch here. if catalog_entry["flow"] == "pkce" and provider_id == "anthropic": - return _start_anthropic_pkce() + return _start_anthropic_pkce(profile=profile) if catalog_entry["flow"] == "device_code": - return await _start_device_code_flow(provider_id) + return await _start_device_code_flow(provider_id, profile=profile) if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth": return await asyncio.get_running_loop().run_in_executor( - None, _start_xai_loopback_flow + None, _start_xai_loopback_flow, profile, ) except HTTPException: raise @@ -6236,18 +6289,27 @@ class OAuthSubmitBody(BaseModel): @app.post("/api/providers/oauth/{provider_id}/submit") -async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): +async def submit_oauth_code( + provider_id: str, + body: OAuthSubmitBody, + request: Request, + profile: Optional[str] = None, +): """Submit the auth code for PKCE flows. Token-protected.""" _require_token(request) if provider_id == "anthropic": return await asyncio.get_running_loop().run_in_executor( - None, _submit_anthropic_pkce, body.session_id, body.code, + None, _submit_anthropic_pkce, body.session_id, body.code, profile, ) raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") -async def poll_oauth_session(provider_id: str, session_id: str): +async def poll_oauth_session( + provider_id: str, + session_id: str, + profile: Optional[str] = None, +): """Poll a session's status (no auth — read-only state). Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the @@ -6270,7 +6332,11 @@ async def poll_oauth_session(provider_id: str, session_id: str): @app.delete("/api/providers/oauth/sessions/{session_id}") -async def cancel_oauth_session(session_id: str, request: Request): +async def cancel_oauth_session( + session_id: str, + request: Request, + profile: Optional[str] = None, +): """Cancel a pending OAuth session. Token-protected.""" _require_token(request) with _oauth_sessions_lock: diff --git a/tests/hermes_cli/test_nous_auth_status_cache.py b/tests/hermes_cli/test_nous_auth_status_cache.py index 5f0e733fb4..0a60ce6adf 100644 --- a/tests/hermes_cli/test_nous_auth_status_cache.py +++ b/tests/hermes_cli/test_nous_auth_status_cache.py @@ -3,8 +3,9 @@ The cache avoids re-validating Nous credentials on every menu paint — `hermes tools` → "All Platforms" used to fire ~31 OAuth refresh POSTs against portal.nousresearch.com during one render. The cache is keyed -on auth.json mtime so login/logout flows invalidate naturally; tests -and other writers can also call invalidate_nous_auth_status_cache(). +on auth.json path + mtime so profile switches stay isolated while +login/logout flows invalidate naturally; tests and other writers can +also call invalidate_nous_auth_status_cache(). """ from __future__ import annotations @@ -88,6 +89,42 @@ def test_get_nous_auth_status_invalidates_on_auth_file_mtime(tmp_path, monkeypat auth_mod.invalidate_nous_auth_status_cache() +def test_get_nous_auth_status_cache_is_scoped_by_auth_file_path(tmp_path, monkeypatch): + """Two profile homes with missing auth.json must not share cached status.""" + profile_a = tmp_path / "profiles" / "a" + profile_b = tmp_path / "profiles" / "b" + profile_a.mkdir(parents=True) + profile_b.mkdir(parents=True) + + from hermes_cli import auth as auth_mod + + auth_mod.invalidate_nous_auth_status_cache() + + call_count = {"n": 0} + seen_auth_files = [] + + def fake_compute(): + call_count["n"] += 1 + seen_auth_files.append(auth_mod._auth_file_path()) + return {"logged_in": False, "call": call_count["n"]} + + with patch.object(auth_mod, "_compute_nous_auth_status", side_effect=fake_compute): + monkeypatch.setenv("HERMES_HOME", str(profile_a)) + first = auth_mod.get_nous_auth_status() + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + second = auth_mod.get_nous_auth_status() + + assert call_count["n"] == 2 + assert first["call"] == 1 + assert second["call"] == 2 + assert seen_auth_files == [ + profile_a / "auth.json", + profile_b / "auth.json", + ] + + auth_mod.invalidate_nous_auth_status_cache() + + def test_invalidate_nous_auth_status_cache_forces_recompute(tmp_path, monkeypatch): """Explicit invalidate forces the next call to re-compute.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 5cb2c30d8b..9b1b853c93 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -34,6 +34,13 @@ client = TestClient(app) HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} +def _make_profile_home(tmp_path, monkeypatch, profile="coder"): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + profile_home = tmp_path / "profiles" / profile + profile_home.mkdir(parents=True) + return profile_home + + def _fake_nous_device_data(): return { "device_code": "device-code", @@ -127,6 +134,67 @@ def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch): ws._oauth_sessions.pop(result["session_id"], None) +def test_oauth_provider_status_uses_profile_query(tmp_path, monkeypatch): + from hermes_cli import web_server as ws + from hermes_constants import get_hermes_home + + profile_home = _make_profile_home(tmp_path, monkeypatch) + observed_homes = [] + + def fake_status(): + observed_homes.append(get_hermes_home()) + return {"logged_in": False, "source": None} + + fake_catalog = ({ + "id": "fake-oauth", + "name": "Fake OAuth", + "flow": "pkce", + "cli_command": "hermes auth add fake-oauth", + "docs_url": "https://example.com", + "status_fn": fake_status, + },) + monkeypatch.setattr(ws, "_OAUTH_PROVIDER_CATALOG", fake_catalog) + + resp = client.get("/api/providers/oauth?profile=coder", headers=HEADERS) + + assert resp.status_code == 200, resp.text + assert observed_homes == [profile_home] + + +def test_oauth_start_stores_profile_for_background_completion(tmp_path, monkeypatch): + from hermes_cli import web_server as ws + + _make_profile_home(tmp_path, monkeypatch) + fake_user_code_resp = { + "user_code": "ABCD-1234", + "verification_uri": "https://api.minimax.io/oauth/verify", + "expired_in": 600, + "interval": 2000, + "state": "stub-state", + } + with patch( + "hermes_cli.auth._minimax_request_user_code", + return_value=fake_user_code_resp, + ), patch( + "hermes_cli.auth._minimax_pkce_pair", + return_value=("verifier-stub", "challenge-stub", "stub-state"), + ), patch( + "hermes_cli.web_server._minimax_poller", + return_value=None, + ): + resp = client.post( + "/api/providers/oauth/minimax-oauth/start?profile=coder", + headers=HEADERS, + ) + + assert resp.status_code == 200, resp.text + session_id = resp.json()["session_id"] + try: + assert ws._oauth_sessions[session_id]["profile"] == "coder" + finally: + ws._oauth_sessions.pop(session_id, None) + + def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws @@ -207,6 +275,71 @@ def test_codex_dashboard_worker_persists_runtime_provider(tmp_path, monkeypatch) ws._oauth_sessions.pop(sid, None) +def test_codex_dashboard_worker_persists_inside_session_profile(tmp_path, monkeypatch): + from hermes_cli import auth as auth_mod + from hermes_cli import web_server as ws + from hermes_constants import get_hermes_home + + profile_home = _make_profile_home(tmp_path, monkeypatch) + + class _Resp: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class _Client: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def post(self, url, **kwargs): + if url.endswith("/deviceauth/usercode"): + return _Resp(200, { + "device_auth_id": "device-auth-id", + "interval": 3, + "user_code": "CODEX-1234", + }) + if url.endswith("/deviceauth/token"): + return _Resp(200, { + "authorization_code": "authorization-code", + "code_verifier": "code-verifier", + }) + return _Resp(200, { + "access_token": "codex-access", + "refresh_token": "codex-refresh", + }) + + saved_homes = [] + monkeypatch.setattr(httpx, "Client", _Client) + monkeypatch.setattr(ws.time, "sleep", lambda _: None) + monkeypatch.setattr( + auth_mod, + "_save_codex_tokens", + lambda tokens: saved_homes.append(get_hermes_home()), + ) + + sid, _ = ws._new_oauth_session( + "openai-codex", + "device_code", + profile="coder", + ) + try: + ws._codex_full_login_worker(sid) + + assert ws._oauth_sessions[sid]["status"] == "approved" + assert saved_homes == [profile_home] + finally: + ws._oauth_sessions.pop(sid, None) + + def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index da85cc26ad..2b37b5788b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6928,6 +6928,8 @@ def test_notification_event_dedup_key_preserves_distinct_watch_matches(): def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): """Distinct watch matches from one process emit; exact replay is deduped.""" + import queue as _queue_mod + from tools.process_registry import process_registry turns = [] @@ -6943,8 +6945,8 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit) - while not process_registry.completion_queue.empty(): - process_registry.completion_queue.get_nowait() + isolated_queue: _queue_mod.Queue = _queue_mod.Queue() + monkeypatch.setattr(process_registry, "completion_queue", isolated_queue) base = { "type": "watch_match", @@ -6954,9 +6956,9 @@ def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): "output": "READY on port 8000", "suppressed": 0, } - process_registry.completion_queue.put(base) - process_registry.completion_queue.put({**base, "output": "READY on port 9000"}) - process_registry.completion_queue.put(dict(base)) + isolated_queue.put(base) + isolated_queue.put({**base, "output": "READY on port 9000"}) + isolated_queue.put(dict(base)) stop = threading.Event() stop.set() From c6b0eb4de0e5010a752e312c0577a4d04d2a08a5 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 15 Jun 2026 23:50:19 -0500 Subject: [PATCH 81/92] fix(desktop): open remote-gateway artifacts via authenticated download (#46895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a remote gateway connection, agent-written files live on the gateway host, not the desktop's disk, so the Artifacts view's file:// hrefs failed ("Invalid external URL") and image thumbnails broke. Make mediaExternalUrl() remote-aware in one place: in remote mode it rewrites gateway-local paths to GET /api/files/download (a new endpoint that streams the file as a Content-Disposition: attachment). The artifacts view now resolves through it, and so do the existing chat-media and generated-image callers, for free. The download endpoint stays auth-gated; auth_middleware additionally accepts the session token as a ?token= query param for this one path so a shell/browser-opened download (which can't set the session header) still authenticates — the same query-token tradeoff as the /api/pty WebSocket. It is NOT added to PUBLIC_API_PATHS. Salvages #46663 (which carried ~19k lines of CRLF noise and made the endpoint public). Reimplemented on a clean LF base with the security hole closed and tests added. Co-authored-by: qingshan89 --- apps/desktop/src/app/artifacts/index.tsx | 12 ++--- apps/desktop/src/lib/media.remote.test.ts | 34 ++++++++++++- apps/desktop/src/lib/media.ts | 19 ++++++- hermes_cli/web_server.py | 49 +++++++++++++++++- tests/hermes_cli/test_web_server_files.py | 60 +++++++++++++++++++++++ 5 files changed, 163 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index 8e98dd9d40..b4dfd994e9 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons' +import { mediaExternalUrl } from '@/lib/media' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' @@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind { } function artifactHref(value: string): string { - if ( - value.startsWith('http://') || - value.startsWith('https://') || - value.startsWith('file://') || - value.startsWith('data:') - ) { + if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) { return value } - if (value.startsWith('/')) { - return `file://${encodeURI(value)}` + if (value.startsWith('file://') || value.startsWith('/')) { + return mediaExternalUrl(value) } return value diff --git a/apps/desktop/src/lib/media.remote.test.ts b/apps/desktop/src/lib/media.remote.test.ts index 9de4885a51..53e5c2212c 100644 --- a/apps/desktop/src/lib/media.remote.test.ts +++ b/apps/desktop/src/lib/media.remote.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $connection } from '@/store/session' -import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media' +import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media' describe('isRemoteGateway', () => { afterEach(() => { @@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => { }) }) +describe('mediaExternalUrl', () => { + afterEach(() => { + $connection.set(null) + }) + + it('passes through http(s) URLs untouched', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never) + expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png') + }) + + it('keeps file:// form in local mode', () => { + $connection.set({ mode: 'local' } as never) + expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png') + expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png') + }) + + it('rewrites gateway-local paths to an authenticated download URL', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never) + expect(mediaExternalUrl('file:///tmp/a b.png')).toBe( + 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret' + ) + expect(mediaExternalUrl('/tmp/a b.png')).toBe( + 'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret' + ) + }) + + it('falls back to file:// when remote connection lacks a token', () => { + $connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never) + expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png') + }) +}) + describe('gatewayMediaDataUrl', () => { const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' })) diff --git a/apps/desktop/src/lib/media.ts b/apps/desktop/src/lib/media.ts index 145558b42a..9c50ce6c75 100644 --- a/apps/desktop/src/lib/media.ts +++ b/apps/desktop/src/lib/media.ts @@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string { return `#media:${encodeURIComponent(path)}` } +// Resolve a media path to a URL the shell can open. Remote mode rewrites +// gateway-local paths to an authenticated /api/files/download URL (the file +// lives on the gateway, not this disk); local mode keeps the file:// form. export function mediaExternalUrl(path: string): string { - return /^(?:https?|file):/i.test(path) ? path : `file://${path}` + if (/^https?:/i.test(path)) { + return path + } + + if (isRemoteGateway()) { + const conn = $connection.get() + + if (conn?.baseUrl && conn.token) { + const file = encodeURIComponent(filePathFromMediaPath(path)) + + return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}` + } + } + + return /^file:/i.test(path) ? path : `file://${path}` } // Custom Electron scheme (registered in electron/main.cjs) that streams a local diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c434fb6751..a75a646835 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -247,6 +247,19 @@ def _has_valid_session_token(request: Request) -> bool: return hmac.compare_digest(auth.encode(), expected.encode()) +# Routes that may also authenticate via a ``?token=`` query param, for download +# links opened by the OS shell or a new browser tab where the session header +# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS. +_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"}) + + +def _has_valid_query_token(request: Request, path: str) -> bool: + if path not in _QUERY_TOKEN_API_PATHS: + return False + token = request.query_params.get("token", "") + return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()) + + def _require_token(request: Request) -> None: """Authorize a sensitive endpoint, raising 401 if the caller isn't allowed. @@ -403,7 +416,7 @@ async def auth_middleware(request: Request, call_next): return await call_next(request) path = request.url.path if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: - if not _has_valid_session_token(request): + if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse( status_code=401, content={"detail": "Unauthorized"}, @@ -1409,6 +1422,40 @@ async def read_managed_file(request: Request, path: str): } +@app.get("/api/files/download") +async def download_managed_file(request: Request, path: str): + """Stream a managed file as an attachment download. + + Remote clients (desktop app, browser dashboard) open agent-written files + that live on *this* gateway's disk, not theirs. Auth-gated like every other + managed-files route — ``auth_middleware`` additionally accepts the session + token as a ``?token=`` query param here so a shell/browser-opened download + (which can't set the session header) still authenticates. See ``/api/pty`` + for the same query-token precedent. + """ + policy, target, _display_path = _resolve_managed_path(path, request) + if not target.exists(): + raise HTTPException(status_code=404, detail="File not found") + if not target.is_file(): + raise HTTPException(status_code=400, detail="Path is not a file") + + try: + size = target.stat().st_size + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") + if size > _MANAGED_FILE_MAX_BYTES: + raise HTTPException(status_code=413, detail="File is too large") + + mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + + return FileResponse( + path=str(target), + media_type=mime_type, + filename=target.name, + content_disposition_type="attachment", + ) + + @app.post("/api/files/upload") async def upload_managed_file(payload: ManagedFileUpload, request: Request): policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True) diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py index 6f4b863317..02096a616a 100644 --- a/tests/hermes_cli/test_web_server_files.py +++ b/tests/hermes_cli/test_web_server_files.py @@ -254,6 +254,66 @@ def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client): assert not folder.exists() +def _seed_file(client, root, name="out/hello.txt"): + file_path = root / name + created = client.post( + "/api/files/upload", + json={"path": str(file_path), "data_url": "data:text/plain;base64,aGVsbG8="}, + ) + assert created.status_code == 200 + return file_path + + +def test_download_returns_file_as_attachment(forced_files_client): + client, root = forced_files_client + file_path = _seed_file(client, root) + + resp = client.get("/api/files/download", params={"path": str(file_path)}) + assert resp.status_code == 200 + assert resp.content == b"hello" + disposition = resp.headers["content-disposition"] + assert "attachment" in disposition + assert "hello.txt" in disposition + + +def test_download_authenticates_via_query_token(forced_files_client): + client, root = forced_files_client + file_path = _seed_file(client, root) + + # Drop the session header so only the ?token= query param authenticates — + # mirrors a browser/shell-opened download that can't set the session header. + del client.headers[web_server._SESSION_HEADER_NAME] + + ok = client.get( + "/api/files/download", + params={"path": str(file_path), "token": web_server._SESSION_TOKEN}, + ) + assert ok.status_code == 200 + assert ok.content == b"hello" + + assert client.get( + "/api/files/download", params={"path": str(file_path), "token": "nope"} + ).status_code == 401 + assert client.get( + "/api/files/download", params={"path": str(file_path)} + ).status_code == 401 + + +def test_query_token_does_not_authenticate_other_endpoints(forced_files_client): + client, root = forced_files_client + file_path = _seed_file(client, root) + + del client.headers[web_server._SESSION_HEADER_NAME] + + # The query-token escape hatch is scoped to /api/files/download only; it must + # not unlock the rest of the API surface. + leaked = client.get( + "/api/files/read", + params={"path": str(file_path), "token": web_server._SESSION_TOKEN}, + ) + assert leaked.status_code == 401 + + def test_hosted_policy_locks_to_opt_data(monkeypatch): monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False) monkeypatch.setenv("HERMES_HOME", "/opt/data") From 5b3fa26366320881d026c4ff824685a5677f9368 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Mon, 15 Jun 2026 13:03:59 -0700 Subject: [PATCH 82/92] fix(photon): unify project identifiers and update documentation for Spectrum provisioning Co-Authored-By: Marvin Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/platforms/photon/README.md | 18 ++-- plugins/platforms/photon/auth.py | 99 ++++++++----------- plugins/platforms/photon/cli.py | 18 ++-- tests/plugins/platforms/photon/test_auth.py | 55 ++--------- .../platforms/photon/test_setup_access.py | 8 +- 5 files changed, 71 insertions(+), 127 deletions(-) diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 1d5d89b57a..1989e271fb 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -54,8 +54,10 @@ hermes gateway start 1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens `https://app.photon.codes/` for approval and stores the bearer token. 2. **Find or create** the `Hermes Agent` project on the Photon dashboard. -3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the - project secret, and persist both. +3. **Provision the project secret** — mint a fresh project secret (the + dashboard reveals it only once) and persist it to `~/.hermes/.env` so the + sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no + separate enable step. 4. **Register your phone number** as a Spectrum user (idempotent — skipped if a user with that number already exists). 5. **Print the assigned iMessage line** — the number you text to reach your @@ -75,7 +77,7 @@ Runtime SDK credentials live in `~/.hermes/.env` (the same place every other channel keeps its token), and the adapter reads them from the environment: ```bash -PHOTON_PROJECT_ID= # the SDK's projectId +PHOTON_PROJECT_ID= # the SDK's projectId (same as the dashboard project id) PHOTON_PROJECT_SECRET= ``` @@ -89,8 +91,8 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`: ], "photon_project": [ { - "dashboard_project_id": "", - "spectrum_project_id": "", + "dashboard_project_id": "", + "spectrum_project_id": "", "project_secret": "", "name": "Hermes Agent" } @@ -99,9 +101,9 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`: } ``` -> **Note on ids.** A Photon project has two identifiers: the dashboard `id` -> (used for management API calls) and the `spectrumProjectId` (what the SDK -> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id. +> **Note on ids.** A Photon project's dashboard id and its Spectrum project id +> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id` +> and `spectrum_project_id` keys in `auth.json` both hold that id. ## Configuration knobs diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 502a6349ea..e4e2421538 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -3,29 +3,29 @@ Photon Dashboard API client + device-code login flow. This module is pure Python — it intentionally does not depend on ``spectrum-ts``. Every management-plane operation (login, find/create -project, enable Spectrum, rotate the project secret, register a user, -list the assigned iMessage line) talks to Photon's **Dashboard API** on a -single host, exactly like the official Photon CLI (``photon-hq/cli``): +project, rotate the project secret, register a user, list the assigned +iMessage line) talks to Photon's **Dashboard API** on a single host, +exactly like the official Photon CLI (``photon-hq/cli``): Dashboard API https://app.photon.codes/api/... OAuth 2.0 device flow, Bearer access token -A Photon project carries two distinct identifiers: - - * ``id`` — the Dashboard project id (used in API paths) - * ``spectrumProjectId`` — the Spectrum Cloud project id, populated when - Spectrum is enabled on the project +A Photon project has a single identifier: the dashboard ``id`` *is* the +Spectrum Cloud project id. They used to diverge (a separate +``spectrumProjectId`` field), but the dashboard unified them — every +project is created with matching ids and the pre-existing diverged rows +were backfilled so ``project.id == spectrumProjectId`` everywhere +(dashboard ENG-1582). Spectrum is always enabled and provisioned at +create-time, so there is no enable/toggle step anymore. The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum -Cloud with ``(spectrumProjectId, projectSecret)`` — so the value we persist -as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not -the Dashboard ``id``. The Dashboard ``id`` is kept only for management -calls. +Cloud with ``(id, projectSecret)`` — the same ``id`` used in Dashboard API +paths — which we persist as ``PHOTON_PROJECT_ID`` for the runtime. Credential storage mirrors every other Hermes channel: * runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = - spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` + project id, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` * management metadata -> ``~/.hermes/auth.json`` under ``credential_pool.photon`` (device token), ``credential_pool.photon_project`` (dashboard id, spectrum id, name), and @@ -148,8 +148,8 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's environment at startup) wins, then ``auth.json`` for offline / status - use. This is the pair the Node sidecar feeds to ``spectrum-ts`` — the id - is the **spectrumProjectId**, not the Dashboard id. + use. This is the pair the Node sidecar feeds to ``spectrum-ts``; the id + is the unified project id (dashboard id == spectrumProjectId). """ env_id = os.getenv("PHOTON_PROJECT_ID") env_sec = os.getenv("PHOTON_PROJECT_SECRET") @@ -166,14 +166,26 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: def load_dashboard_project_id() -> Optional[str]: - """Return the Dashboard project id (for management API calls).""" + """Return the project id used for management API calls. + + Post-unification the dashboard id and the Spectrum id are the same value, + so we prefer the stored ``spectrum_project_id``: for pre-backfill installs + the old ``dashboard_project_id`` is the diverged id that the unification + rewrote (it now 404s), while the Spectrum id always matches the live row. + Falls back to the legacy keys for older records. + """ env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID") if env_id: return env_id auth = _load_auth() proj = auth.get("credential_pool", {}).get("photon_project") or [] if isinstance(proj, list) and proj: - return proj[0].get("dashboard_project_id") or proj[0].get("project_id") + entry = proj[0] + return ( + entry.get("spectrum_project_id") + or entry.get("dashboard_project_id") + or entry.get("project_id") + ) return None @@ -646,30 +658,23 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]: return None -def get_project(token: str, project_id: str) -> Dict[str, Any]: - """GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``.""" - if httpx is None: - raise RuntimeError("httpx is required for Photon") - url = f"{_dashboard_host()}/api/projects/{project_id}" - resp = httpx.get(url, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() - return resp.json() or {} - - def create_project( token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States", ) -> Dict[str, Any]: - """POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``.""" + """POST ``/api/projects`` and return ``{success, id}``. + + Spectrum is always provisioned at create-time, so the request body no + longer carries a ``spectrum`` flag (the field was dropped from the API). + """ if httpx is None: raise RuntimeError("httpx is required for Photon project creation") url = f"{_dashboard_host()}/api/projects" body: Dict[str, Any] = { "name": name, "location": location, - "spectrum": True, "template": False, "observability": False, } @@ -683,29 +688,6 @@ def create_project( return data -def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]: - """Enable Spectrum on the project if needed; return the project dict. - - The dashboard exposes Spectrum as a toggle, so we only flip it when - ``spectrum`` is currently false, then re-fetch to pick up the freshly - populated ``spectrumProjectId``. - """ - if httpx is None: - raise RuntimeError("httpx is required for Photon") - proj = get_project(token, project_id) - if not proj.get("spectrum"): - url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle" - resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() - proj = get_project(token, project_id) - if not proj.get("spectrumProjectId"): - raise RuntimeError( - "Spectrum is enabled but the project has no spectrumProjectId yet — " - "retry in a moment, or enable Spectrum from the dashboard." - ) - return proj - - def regenerate_project_secret(token: str, project_id: str) -> str: """POST ``/api/projects/{id}/regenerate-secret`` → the new project secret. @@ -1007,8 +989,9 @@ def print_credential_summary(emit: Any = print) -> None: else "✗ missing (run `hermes photon setup`)" ) sid, sec = load_project_credentials() - labels["spectrum_project_id"] = sid if sid else "✗ missing" - labels["dashboard_project_id"] = load_dashboard_project_id() or "—" + # Dashboard id and Spectrum id are the same value now (ids unified), so + # there's a single project id to show. + labels["project_id"] = sid if sid else "✗ missing" labels["project_key"] = "✓ stored" if sec else "✗ missing" phone, assigned = load_user_numbers() labels["phone_number"] = ( @@ -1022,8 +1005,7 @@ def print_credential_summary(emit: Any = print) -> None: "Photon iMessage status", "──────────────────────", " device token : " + labels["device_token"], - " dashboard project : " + labels["dashboard_project_id"], - " spectrum project id : " + labels["spectrum_project_id"], + " project id : " + labels["project_id"], " project secret : " + labels["project_key"], " my number : " + labels["phone_number"], " assigned number : " + labels["assigned_phone_number"], @@ -1039,7 +1021,7 @@ def credential_summary() -> Dict[str, str]: else "✗ missing (run `hermes photon setup`)" ) - def _present_spectrum_id() -> str: + def _present_project_id() -> str: sid, _sec = load_project_credentials() return sid or "✗ missing" @@ -1057,8 +1039,7 @@ def credential_summary() -> Dict[str, str]: return { "device_token": _present_token(), - "dashboard_project_id": load_dashboard_project_id() or "—", - "spectrum_project_id": _present_spectrum_id(), + "project_id": _present_project_id(), "project_key": _present_secret(), "phone_number": _present_phone(), "assigned_phone_number": _present_assigned_phone(), diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 99a6c6ee72..e203d4d144 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -164,16 +164,14 @@ def _cmd_setup(args: argparse.Namespace) -> int: print("could not resolve a Photon project id", file=sys.stderr) return 1 - # 3. Enable Spectrum, fetch the spectrum project id, rotate the secret, - # and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json). + # 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env, + # ids -> auth.json). Spectrum is always enabled and provisioned at + # create-time, and the dashboard project id *is* the Spectrum project id + # (ids unified), so there's nothing to enable — the id we already have is + # the Spectrum id. try: - print("[3/5] Enabling Spectrum and provisioning credentials...") - proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id) - spectrum_id = proj.get("spectrumProjectId") - if not spectrum_id: - print("spectrum provisioning failed: no spectrum project id", file=sys.stderr) - return 1 - spectrum_id = str(spectrum_id) + print("[3/5] Provisioning Spectrum credentials...") + spectrum_id = dashboard_id secret = photon_auth.regenerate_project_secret(token, dashboard_id) photon_auth.store_project_credentials( spectrum_project_id=spectrum_id, @@ -182,7 +180,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: name=name, ) # spectrum_id is an opaque non-secret id; safe to show. - print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved") + print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved") except Exception as e: print(f"spectrum provisioning failed: {e}", file=sys.stderr) return 1 diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 9faf7833d4..b3635fddd5 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -88,7 +88,10 @@ def test_store_project_credentials_round_trip( sid, secret = photon_auth.load_project_credentials() assert sid == "sp-123" assert secret == "secret-key" - assert photon_auth.load_dashboard_project_id() == "dash-456" + # Post-unification the management id resolves to the Spectrum id, not the + # stored dashboard id — so a pre-backfill diverged install (whose old + # dashboard id was rewritten and now 404s) still reaches the live row. + assert photon_auth.load_dashboard_project_id() == "sp-123" def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None: @@ -284,7 +287,7 @@ def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) assert proj is not None and proj["id"] == "p2" -def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None: captured: Dict[str, Any] = {} def fake_post(url: str, **kwargs: Any) -> _FakeResponse: @@ -296,7 +299,9 @@ def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(photon_auth.httpx, "post", fake_post) data = photon_auth.create_project("tok", name="Hermes Agent") assert data["id"] == "new-proj" - assert captured["body"]["spectrum"] is True + # Spectrum is always provisioned at create-time; the field was dropped + # from the API schema, so we must not send it. + assert "spectrum" not in captured["body"] assert captured["body"]["name"] == "Hermes Agent" assert captured["headers"]["Authorization"] == "Bearer tok" assert captured["url"].endswith("/api/projects") @@ -311,46 +316,6 @@ def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> No photon_auth.create_project("tok") -def test_ensure_spectrum_enabled_toggles_when_off(monkeypatch: pytest.MonkeyPatch) -> None: - get_calls = {"n": 0} - posted = {"toggle": False} - - def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - get_calls["n"] += 1 - if get_calls["n"] == 1: - return _FakeResponse(json_body={"id": "p", "spectrum": False, "spectrumProjectId": None}) - return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) - - def fake_post(url: str, **kwargs: Any) -> _FakeResponse: - if url.endswith("/spectrum/toggle"): - posted["toggle"] = True - return _FakeResponse(json_body={"success": True}) - - monkeypatch.setattr(photon_auth.httpx, "get", fake_get) - monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - proj = photon_auth.ensure_spectrum_enabled("tok", "p") - assert posted["toggle"] is True - assert proj["spectrumProjectId"] == "sp-1" - - -def test_ensure_spectrum_enabled_skips_toggle_when_on(monkeypatch: pytest.MonkeyPatch) -> None: - posted = {"toggle": False} - - def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) - - def fake_post(url: str, **kwargs: Any) -> _FakeResponse: - if url.endswith("/spectrum/toggle"): - posted["toggle"] = True - return _FakeResponse(json_body={"success": True}) - - monkeypatch.setattr(photon_auth.httpx, "get", fake_get) - monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - proj = photon_auth.ensure_spectrum_enabled("tok", "p") - assert posted["toggle"] is False - assert proj["spectrumProjectId"] == "sp-1" - - def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None: def fake_post(url: str, **kwargs: Any) -> _FakeResponse: assert url.endswith("/regenerate-secret") @@ -498,8 +463,8 @@ def test_credential_summary_no_secret_leak( assert "secret-bbbb" not in blob assert summary["device_token"].startswith("✓") assert summary["project_key"].startswith("✓") - assert summary["spectrum_project_id"] == "sp-uuid" - assert summary["dashboard_project_id"] == "dash-uuid" + # Unified id: dashboard id == Spectrum id, surfaced as one project id. + assert summary["project_id"] == "sp-uuid" assert summary["phone_number"].startswith("✗ missing") assert summary["assigned_phone_number"].startswith("✗ missing") diff --git a/tests/plugins/platforms/photon/test_setup_access.py b/tests/plugins/platforms/photon/test_setup_access.py index ec41896797..ef27957cde 100644 --- a/tests/plugins/platforms/photon/test_setup_access.py +++ b/tests/plugins/platforms/photon/test_setup_access.py @@ -73,12 +73,10 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None: monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token") + # The dashboard id *is* the Spectrum project id (ids unified), so setup no + # longer enables Spectrum or fetches a separate spectrumProjectId — it + # reuses this id directly. monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard") - monkeypatch.setattr( - cli.photon_auth, - "ensure_spectrum_enabled", - lambda token, dashboard_id: {"spectrumProjectId": "project_123"}, - ) monkeypatch.setattr( cli.photon_auth, "regenerate_project_secret", From a6364bfa08dbc73d978b4800f4a3d28257bcab4e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:26:04 -0700 Subject: [PATCH 83/92] fix(telegram): edit streamed previews in place as rich (Bot API 10.1) (#46890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamed Telegram replies that finalize through editMessageText were converted to MarkdownV2, which has no table syntax and rewrites pipe tables into bullet lists — users saw a table while streaming that collapsed to a list at the last moment. Finalize now edits the existing preview IN PLACE via Bot API 10.1's editMessageText rich_message parameter when the content has constructs the legacy path degrades (tables, task lists,
, block math). No fresh send + delete, so no duplicate-preview flicker — the reason #46206 reverted the fresh-final re-send path. prefers_fresh_final_streaming stays False; the in-place edit replaces it. - _needs_rich_rendering(): rich reserved for table/task-list/details/math (adapted from #45995, @YonganZhang); plain replies stay on MarkdownV2. - _try_edit_rich(): editMessageText + rich_message via do_api_request, mirroring _try_send_rich's fallback/latch/transient contract. - edit_message finalize tries rich in place before the 4,096 overflow pre-flight (rich cap is 32,768), falling back to legacy on rejection. - rich_messages default flipped back to True (DEFAULT_CONFIG + adapter). - docs (en + zh-Hans) + cli-config example updated to default-on. Closes the root cause behind #45911 / #46009. --- cli-config.yaml.example | 2 +- gateway/platforms/telegram.py | 161 +++++++++++++-- hermes_cli/config.py | 2 +- tests/gateway/test_config.py | 4 +- tests/gateway/test_telegram_rich_messages.py | 187 +++++++++++++++++- website/docs/user-guide/messaging/telegram.md | 8 +- .../current/user-guide/messaging/telegram.md | 8 +- 7 files changed, 336 insertions(+), 36 deletions(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e45132f006..8d3525019c 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -724,7 +724,7 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages -# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 +# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2 # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 6516c16540..0fede455a9 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter): self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False) - # Bot API 10.1 Rich Messages: when explicitly enabled, send final - # replies via sendRichMessage with the raw agent markdown so - # tables/task lists/etc. render natively. Disabled by default because - # several Telegram clients accept but render rich messages poorly. - self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False) + # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2 + # path degrades (tables → bullet lists, task lists,
, block + # math) via sendRichMessage / editMessageText's rich_message param using + # the raw agent markdown. Enabled by default; users can opt out for + # clients that accept but render rich messages poorly via + # platforms.telegram.extra.rich_messages: false. + self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True) # Latched off after a capability failure on sendRichMessage / # sendRichMessageDraft (e.g. older python-telegram-bot without the # endpoint) so later sends skip the doomed rich attempt entirely. @@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter): return True return False + def _needs_rich_rendering(self, content: str) -> bool: + """Return True for markdown constructs that the legacy path degrades. + + Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram + clients render a consistent font weight/spacing. The rich endpoint is + reserved for constructs where raw markdown materially improves output: + pipe tables (MarkdownV2 has no table syntax and rewrites them into + bullet lists), GFM task lists, collapsible ``
`` blocks, and + block math. Adapted from #45995 (@YonganZhang). + """ + if not content: + return False + if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()): + return True + if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content): + return True + if re.search(r"(?m)^|^", content): + return True + if "$$" in content: + return True + return False + + def _rich_eligible(self, content: str) -> bool: + """Capability/content eligibility for rich, ignoring ``expect_edits``. + + Shared core of :meth:`_should_attempt_rich` minus the per-call + ``expect_edits`` metadata gate. The rich EDIT-finalize path + (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with + ``expect_edits=True`` to stay on the editable path mid-stream, but the + FINAL edit should still upgrade to rich when the content warrants it. + """ + return bool( + getattr(self, "_rich_messages_enabled", True) + and not getattr(self, "_rich_send_disabled", False) + and content + and content.strip() + and self._needs_rich_rendering(content) + and not self._has_telegram_desktop_details_math_crash_shape(content) + and self._content_fits_rich_limits(content) + and self._bot_supports_rich() + ) + def _should_attempt_rich( self, content: str, metadata: Optional[Dict[str, Any]] = None ) -> bool: return bool( - getattr(self, "_rich_messages_enabled", False) - and not getattr(self, "_rich_send_disabled", False) - and not (metadata or {}).get("expect_edits") - and content - and content.strip() - and not self._has_telegram_desktop_details_math_crash_shape(content) - and self._content_fits_rich_limits(content) - and self._bot_supports_rich() + not (metadata or {}).get("expect_edits") + and self._rich_eligible(content) ) def prefers_fresh_final_streaming( @@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter): ) -> bool: """Whether to replace a streamed preview with a fresh rich final. - Keep this disabled for Telegram. The fresh-final path briefly shows two - copies of the final answer, then deletes the streaming preview after the - rich send succeeds. That is especially visible on clients that support - rich messages well, and it looks like duplicate delivery at the end of - every streamed turn. Until Telegram rich edits are wired directly, final - streamed replies should edit the existing preview in place. + Disabled for Telegram. The fresh-final path briefly shows two copies of + the final answer, then deletes the streaming preview after the rich send + succeeds — it looks like duplicate delivery at the end of every streamed + turn (the reason #46206 reverted it). Rich finalize is instead handled + by editing the existing preview in place via Bot API 10.1's + ``editMessageText`` ``rich_message`` parameter (see + :meth:`_try_edit_rich`), so no fresh re-send / delete is needed. """ return False @@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter): streams split exactly as before. """ if ( - getattr(self, "_rich_messages_enabled", False) + getattr(self, "_rich_messages_enabled", True) and not getattr(self, "_rich_send_disabled", False) and self._bot_supports_rich() ): @@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter): message_id=str(message_id) if message_id is not None else None, ) + async def _try_edit_rich( + self, + chat_id: str, + message_id: str, + content: str, + ) -> Optional[SendResult]: + """Edit an existing message in place as a rich message (Bot API 10.1). + + Uses ``editMessageText`` with the ``rich_message`` parameter so a + streamed preview can finalize as rich (tables/task lists/details/math) + WITHOUT a fresh send + delete — no duplicate preview. Mirrors + :meth:`_try_send_rich`'s error contract: + + - success → ``SendResult(success=True, message_id=...)`` + - permanent / capability error → ``None`` (caller falls back to the + legacy MarkdownV2 edit; capability errors latch rich off) + - transient / unknown → ``SendResult(success=False)`` with retry + semantics (the message may already be edited; do NOT legacy-resend) + """ + payload: Dict[str, Any] = { + "chat_id": int(chat_id), + "message_id": int(message_id), + "rich_message": self._rich_message_payload(content), + } + if getattr(self, "_disable_link_previews", False): + payload["link_preview_options"] = {"is_disabled": True} + try: + # Raw Bot API result; do not request return_type=Message (PTB does + # not fully model the 10.1 response shape yet — a post-edit parse + # error must not be mistaken for a failed edit). + await self._bot.do_api_request("editMessageText", api_kwargs=payload) + except Exception as exc: + if self._is_rich_fallback_error(exc): + if self._is_rich_capability_error(exc): + self._rich_send_disabled = True + # "Message is not modified" — content identical to the current + # rich message; treat as a successful no-op so the caller does + # not fall through to a redundant legacy edit. + if "not modified" in str(exc).lower(): + return SendResult(success=True, message_id=message_id) + logger.debug( + "[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit", + self.name, exc, + ) + return None + if "not modified" in str(exc).lower(): + return SendResult(success=True, message_id=message_id) + err_str = str(exc).lower() + try: + from telegram.error import TimedOut as _TimedOut + except (ImportError, AttributeError): + _TimedOut = None + is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str + is_connect_timeout = self._looks_like_connect_timeout(exc) + logger.warning( + "[%s] rich editMessageText transient failure (no legacy resend): %s", + self.name, exc, + ) + return SendResult( + success=False, + error=str(exc), + retryable=(is_connect_timeout or not is_timeout), + ) + return SendResult(success=True, message_id=message_id) + def _should_attempt_rich_draft(self, content: str) -> bool: return bool( - getattr(self, "_rich_messages_enabled", False) + getattr(self, "_rich_messages_enabled", True) and not getattr(self, "_rich_send_disabled", False) and not getattr(self, "_rich_draft_disabled", False) and content @@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter): if not self._bot: return SendResult(success=False, error="Not connected") + # Rich finalize (Bot API 10.1): when the completed content has + # constructs the legacy MarkdownV2 edit degrades (tables → bullet + # lists, task lists,
, block math) and rich is available, + # edit the preview IN PLACE via editMessageText's rich_message param. + # No fresh send + delete → no duplicate preview (the problem #46206 + # reverted the fresh-final path for). Attempted before the 4,096 + # overflow pre-flight because the rich text cap is 32,768 — a rich + # table that exceeds the MarkdownV2 limit must not be split into legacy + # chunks. Falls back to the legacy edit path (overflow split included) + # on capability/permanent rejection. + if finalize and self._rich_eligible(content): + rich_result = await self._try_edit_rich(chat_id, message_id, content) + if rich_result is not None: + return rich_result + # Pre-flight: if content already exceeds the limit, split-and-deliver # without round-tripping a doomed edit. if utf16_len(content) > self.MAX_MESSAGE_LENGTH: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3a09825620..f374055eac 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1991,7 +1991,7 @@ DEFAULT_CONFIG = { "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) "allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist) "extra": { - "rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 + "rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2 }, }, diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 0d5e2828a6..9e74dd355a 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -832,7 +832,7 @@ class TestLoadGatewayConfig: assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False - def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch): + def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -842,7 +842,7 @@ class TestLoadGatewayConfig: config = load_config() - assert config["telegram"]["extra"]["rich_messages"] is False + assert config["telegram"]["extra"]["rich_messages"] is True def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index 9b8d479f2a..78832a41e2 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -61,6 +61,8 @@ def _make_adapter(extra=None): bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) bot.send_chat_action = AsyncMock() # keeps the post-send typing re-trigger quiet bot.send_message_draft = AsyncMock(return_value=True) # legacy draft fallback + bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1)) # legacy edit path + bot.delete_message = AsyncMock(return_value=True) adapter._bot = bot return adapter @@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false(): @pytest.mark.asyncio -async def test_rich_messages_default_is_disabled(): +async def test_rich_messages_default_is_enabled(): + """Rich messages are on by default (Bot API 10.1); rich-eligible content + (tables/task lists/details/math) goes through sendRichMessage without the + user having to opt in.""" config = PlatformConfig(enabled=True, token="fake-token") adapter = TelegramAdapter(config) bot = MagicMock() @@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled(): result = await adapter.send("12345", RICH_CONTENT) + assert result.success is True + bot = adapter._bot + assert bot is not None + bot.do_api_request.assert_awaited_once() + bot.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_rich_messages_can_be_opted_out(): + """Setting platforms.telegram.extra.rich_messages: false keeps every reply + on the legacy MarkdownV2 path even for rich-eligible content.""" + config = PlatformConfig( + enabled=True, token="fake-token", extra={"rich_messages": False} + ) + adapter = TelegramAdapter(config) + bot = MagicMock() + bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123)) + bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) + bot.send_chat_action = AsyncMock() + adapter._bot = bot + + result = await adapter.send("12345", RICH_CONTENT) + + assert result.success is True + bot.do_api_request.assert_not_called() + bot.send_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_plain_markdown_stays_on_legacy_path(): + """Ordinary replies (no table/task-list/details/math) stay on the legacy + MarkdownV2 path for consistent client rendering, even with rich enabled.""" + adapter = _make_adapter() + + result = await adapter.send("12345", "Hello **there**\n\nA normal reply.") + assert result.success is True bot = adapter._bot assert bot is not None @@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks(): async def test_rich_limit_is_characters_not_bytes(): """Telegram's rich limit is UTF-8 characters, not encoded bytes.""" adapter = _make_adapter() - cjk = "测" * 20000 # 20k chars, 60k UTF-8 bytes + # Rich-eligible (table) so the content takes the rich path; the CJK body + # is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap. + cjk = "| a | b |\n|---|---|\n" + "测" * 20000 # 20k chars, ~60k UTF-8 bytes assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS @@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc): async def test_rich_payload_preserves_link_preview_disable(): adapter = _make_adapter(extra={"disable_link_previews": True}) - result = await adapter.send("12345", "See https://example.com") + result = await adapter.send( + "12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |" + ) assert result.success is True api_kwargs = _rich_api_kwargs(adapter) @@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy(): assert bot is not None bot.do_api_request.assert_not_called() bot.send_message_draft.assert_awaited_once() + + +# ---------------------------------------------------------------------------- +# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param). +# Streamed previews finalize by editing the existing message IN PLACE as rich, +# so tables/task lists survive without a fresh send + delete (no duplicate). +# ---------------------------------------------------------------------------- + + +def _rich_edit_kwargs(adapter): + """Return the api_kwargs dict from the single editMessageText rich call.""" + call = adapter._bot.do_api_request.call_args + assert call.args[0] == "editMessageText" + return call.kwargs["api_kwargs"] + + +@pytest.mark.asyncio +async def test_finalize_edit_uses_rich_for_table_content(): + """Finalizing a streamed preview whose content is a table edits the + existing message IN PLACE via editMessageText's rich_message param — + no fresh send, no delete, no duplicate.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + assert result.message_id == "555" # same message, edited in place + api_kwargs = _rich_edit_kwargs(adapter) + assert api_kwargs["message_id"] == 555 + # RAW markdown is passed through so table pipes survive. + assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT + # No fresh send / delete — the whole point of the in-place rich edit. + adapter._bot.edit_message_text.assert_not_called() + adapter._bot.delete_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_finalize_edit_plain_content_stays_legacy(): + """Finalizing plain content (no table/task-list/details/math) uses the + legacy MarkdownV2 edit_message_text path, not the rich edit endpoint.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", "Just a normal answer, no rich constructs.", finalize=True, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_capability_error_falls_back_to_legacy(): + """A capability error on the rich edit latches rich off and falls back to + the legacy MarkdownV2 edit so the user still gets the final answer.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + assert adapter._rich_send_disabled is True + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_not_modified_is_success_noop(): + """'Message is not modified' on a rich edit is a no-op success — must NOT + fall through to a redundant legacy edit.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock( + side_effect=BadRequest("Message is not modified") + ) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + adapter._bot.edit_message_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_non_finalize_edit_never_uses_rich(): + """Intermediate (non-finalize) stream edits stay on the plain edit path; + rich is only applied on the final edit.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=False, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_opt_out_uses_legacy(): + """With rich_messages: false, even a table finalizes via the legacy + MarkdownV2 edit path.""" + adapter = _make_adapter(extra={"rich_messages": False}) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_over_markdownv2_limit_not_split(): + """A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768 + rich cap is edited in place as one rich message, NOT split into legacy + chunks.""" + adapter = _make_adapter() + big_table = "| a | b |\n|---|---|\n" + "\n".join( + f"| {'x' * 50} | {'y' * 50} |" for _ in range(40) + ) + assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH + assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS + + result = await adapter.edit_message( + "12345", "555", big_table, finalize=True, + ) + + assert result.success is True + api_kwargs = _rich_edit_kwargs(adapter) + assert api_kwargs["rich_message"]["markdown"] == big_table + adapter._bot.edit_message_text.assert_not_called() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index e52bfac924..c255802bbb 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -900,23 +900,23 @@ gateway: ## Rendering: Rich Messages, Tables and Link Previews -**Rich Messages (Bot API 10.1).** When opted in, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `
`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. +**Rich Messages (Bot API 10.1).** Final replies that contain constructs the legacy MarkdownV2 path degrades — tables, task lists, collapsible `
`, and block math — are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so they render natively with no client-side flattening. During streaming, the final answer is delivered by **editing the existing preview in place** via `editMessageText`'s `rich_message` parameter — no second message, no delete, so there is no duplicate-delivery flicker at the end of a turn. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. Ordinary replies (plain prose, bold/italic, simple lists) stay on the MarkdownV2 path for consistent font weight and spacing across clients. -The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message). +The rich path is skipped automatically when content exceeds the 32,768-character rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message). **MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized: - **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 2–4 columns and short cells. - **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses. -Rich messages are disabled by default because some Telegram clients accept the Bot API payload but render it poorly. To opt in for clients that handle rich messages well: +Rich messages are **enabled by default**. Some Telegram clients accept the Bot API payload but render it poorly; to opt out and force every reply onto the legacy MarkdownV2 path: ```yaml gateway: platforms: telegram: extra: - rich_messages: true + rich_messages: false ``` This setting is for client-rendering compatibility; Hermes already falls back automatically when Telegram rejects the rich API call. If you only want the legacy "always code-block" table behavior while keeping rich messages enabled, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md index 06dd22e694..facbb23da1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md @@ -877,23 +877,23 @@ gateway: ## 渲染:富消息、表格和链接预览 -**富消息(Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。 +**富消息(Bot API 10.1)。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `
` 以及块级数学公式——会通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,从而原生渲染、无需客户端展平。在流式传输过程中,最终答案通过 `editMessageText` 的 `rich_message` 参数**就地编辑现有预览**来交付——不发第二条消息、不删除,因此一轮结束时不会出现重复投递的闪烁。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。普通回复(纯文本、粗体/斜体、简单列表)仍走 MarkdownV2 路径,以在各客户端保持一致的字重和间距。 -当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 +当内容超过 32,768 字符的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 **MarkdownV2 回退。** 当某条消息无法使用富消息路径时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化: - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。 - **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。 -富消息默认关闭,因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息,可以选择启用: +富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差;若要关闭并强制所有回复走旧版 MarkdownV2 路径: ```yaml gateway: platforms: telegram: extra: - rich_messages: true + rich_messages: false ``` 这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。 From 20b1f4f3fb865d0399685ff4e21b2855110b2148 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Tue, 31 Mar 2026 15:15:34 +0200 Subject: [PATCH 84/92] feat(memory): configurable background memory update notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background memory reviews now support three notification modes, configured via display.memory_notifications in config.yaml: off — no chat notification (still logged to stdout/HA log) on — generic '💾 Memory updated' (default, unchanged behavior) verbose — content preview with action indicators: 💾 Memory ➕ Hermes Repo liegt unter /config/amy/hermes-agent/... 💾 Memory ✏️ Updated repo path from claude-code to hermes-agent... 💾 Memory ➖ old entry about claude-code path... Previews are truncated to 120 chars for adds/replaces, 60 for removes. Each action gets its own line in verbose mode for readability. Files: run_agent.py, gateway/run.py --- agent/agent_init.py | 1 + agent/background_review.py | 99 ++++++++++++++++++++++++++++++++------ gateway/run.py | 8 +++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index e1594b4585..2c2ded871e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -299,6 +299,7 @@ def init_agent( # would mangle the escape sequences. None = use builtins.print. agent._print_fn = None agent.background_review_callback = None # Optional sync callback for gateway delivery + agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose" agent.skip_context_files = skip_context_files agent.load_soul_identity = load_soul_identity agent.pass_session_id = pass_session_id diff --git a/agent/background_review.py b/agent/background_review.py index d9f6ea5950..5d3fc2bf5e 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = ( def summarize_background_review_actions( review_messages: List[Dict], prior_snapshot: List[Dict], + notification_mode: str = "on", ) -> List[str]: """Build the human-facing action summary for a background review pass. - Walks the review agent's session messages and collects "successful tool - action" descriptions to surface to the user (e.g. "Memory updated"). - Tool messages already present in ``prior_snapshot`` are skipped so we - don't re-surface stale results from the prior conversation that the - review agent inherited via ``conversation_history`` (issue #14944). + Walks the review agent's session messages and collects successful memory + and skill-management actions to surface to the user. Tool messages already + present in ``prior_snapshot`` are skipped so stale inherited results are + not re-surfaced as fresh background work (issue #14944). - Matching is by ``tool_call_id`` when available, with a content-equality - fallback for tool messages that lack one. + ``notification_mode`` controls display detail: + - ``off``: return no actions. + - ``on``: generic "Memory updated"/tool messages. + - ``verbose``: include compact content previews from tool-call arguments. """ + mode = str(notification_mode or "on").lower() + if mode == "off": + return [] + verbose = mode == "verbose" + existing_tool_call_ids = set() existing_tool_contents = set() for prior in prior_snapshot or []: @@ -262,6 +269,36 @@ def summarize_background_review_actions( if isinstance(content, str): existing_tool_contents.add(content) + # Map review-agent tool results back to the calls that produced them. The + # result JSON only says "Entry added"; the call arguments contain action, + # target, and content previews. Restricting to notify_tools also prevents + # helper tools from surfacing as memory work just because they succeeded. + notify_tools = {"memory", "skill_manage"} + call_details: dict = {} + for msg in review_messages or []: + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + for tc in msg.get("tool_calls", []) or []: + if not isinstance(tc, dict): + continue + fn = tc.get("function", {}) or {} + fn_name = fn.get("name", "") + if fn_name not in notify_tools: + continue + try: + args = json.loads(fn.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {} + tcid = tc.get("id") + if tcid: + call_details[tcid] = { + "tool": fn_name, + "action": args.get("action", "?"), + "target": args.get("target", "memory"), + "content": args.get("content", ""), + "old_text": args.get("old_text", ""), + } + actions: List[str] = [] for msg in review_messages or []: if not isinstance(msg, dict) or msg.get("role") != "tool": @@ -273,6 +310,8 @@ def summarize_background_review_actions( content_str = msg.get("content") if isinstance(content_str, str) and content_str in existing_tool_contents: continue + if tcid and call_details and tcid not in call_details: + continue try: data = json.loads(msg.get("content", "{}")) except (json.JSONDecodeError, TypeError): @@ -281,18 +320,45 @@ def summarize_background_review_actions( continue message = data.get("message", "") target = data.get("target", "") - if "created" in message.lower(): + detail = call_details.get(tcid, {}) + is_skill = detail.get("tool") == "skill_manage" + + if is_skill: + label = "Skill" + elif target: + label = "Memory" if target == "memory" else "User profile" if target == "user" else target + else: + continue + + if verbose: + action = detail.get("action", "") + content = detail.get("content", "") + old_text = detail.get("old_text", "") + max_preview = 120 + if is_skill: + actions.append(f"📝 {message}" if message else f"Skill {action}") + elif action == "add" and content: + preview = content[:max_preview] + ("…" if len(content) > max_preview else "") + actions.append(f"{label} ➕ {preview}") + elif action == "replace" and content: + preview = content[:max_preview] + ("…" if len(content) > max_preview else "") + actions.append(f"{label} ✏️ {preview}") + elif action == "remove" and old_text: + preview = old_text[:60] + ("…" if len(old_text) > 60 else "") + actions.append(f"{label} ➖ {preview}") + else: + actions.append(f"{label} updated") + elif "created" in message.lower(): actions.append(message) elif "updated" in message.lower(): actions.append(message) - elif "added" in message.lower() or (target and "add" in message.lower()): - label = "Memory" if target == "memory" else "User profile" if target == "user" else target - actions.append(f"{label} updated") - elif "Entry added" in message: - label = "Memory" if target == "memory" else "User profile" if target == "user" else target - actions.append(f"{label} updated") - elif "removed" in message.lower() or "replaced" in message.lower(): - label = "Memory" if target == "memory" else "User profile" if target == "user" else target + elif ( + "added" in message.lower() + or "replaced" in message.lower() + or "removed" in message.lower() + or (target and "add" in message.lower()) + or "Entry added" in message + ): actions.append(f"{label} updated") return actions @@ -522,6 +588,7 @@ def _run_review_in_thread( actions = summarize_background_review_actions( review_messages, messages_snapshot, + notification_mode=getattr(agent, "memory_notifications", "on"), ) if actions: diff --git a/gateway/run.py b/gateway/run.py index 1c29a593e3..2c8f14008d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -14697,6 +14697,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _pdc = getattr(_status_adapter, "_post_delivery_callbacks", None) if _pdc is not None: _pdc[session_key] = _release_bg_review_messages + # Memory update notifications in chat. Config: display.memory_notifications + # off — no chat notification (still logged to stdout) + # on — generic "💾 Memory updated" (default) + # verbose — content preview: "💾 Memory ➕ Hermes Repo..." + _mem_notif = user_config.get("display", {}).get("memory_notifications") + if isinstance(_mem_notif, bool): + _mem_notif = "on" if _mem_notif else "off" + agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on" # ------------------------------------------------------------------ # Clarify callback: present a clarify prompt and block on a response. From 4cf9d80fba1eddd0a187e6f29d4531c8f2dc1610 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Fri, 10 Apr 2026 10:32:32 +0200 Subject: [PATCH 85/92] feat(display): verbose skill change notifications with content previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When display.memory_notifications is set to 'verbose', skill_manage notifications now show meaningful change details instead of just the generic tool message. Before (verbose mode): 💾 📝 Patched SKILL.md in skill 'gogcli' (1 replacement). After (verbose mode): 💾 📝 Skill 'gogcli' patched: "old pitfall text..." → "new pitfall text..." Changes: - skill_manager_tool.py: _patch_skill() now includes old/new string previews (truncated to 200 chars) in the result via '_change' key. _create_skill() and _edit_skill() include skill description from frontmatter for verbose create/edit notifications. - run_agent.py: Background review notification builder now reads the '_change' dict from skill tool results and formats descriptive notifications per action type (patch → old→new diff, create/edit → description preview). Falls back to generic message when _change data is unavailable (backwards compatible). This is especially useful when subagents patch skills, since neither the user nor the parent agent can see what the subagent changed. --- agent/background_review.py | 57 ++++++++++++++++++----- run_agent.py | 7 ++- tests/run_agent/test_background_review.py | 4 +- tools/skill_manager_tool.py | 32 ++++++++++++- 4 files changed, 85 insertions(+), 15 deletions(-) diff --git a/agent/background_review.py b/agent/background_review.py index 5d3fc2bf5e..2c9703ba68 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -274,6 +274,7 @@ def summarize_background_review_actions( # target, and content previews. Restricting to notify_tools also prevents # helper tools from surfacing as memory work just because they succeeded. notify_tools = {"memory", "skill_manage"} + all_tool_call_ids: set = set() call_details: dict = {} for msg in review_messages or []: if not isinstance(msg, dict) or msg.get("role") != "assistant": @@ -283,13 +284,15 @@ def summarize_background_review_actions( continue fn = tc.get("function", {}) or {} fn_name = fn.get("name", "") + tcid = tc.get("id") + if tcid: + all_tool_call_ids.add(tcid) if fn_name not in notify_tools: continue try: args = json.loads(fn.get("arguments", "{}")) except (json.JSONDecodeError, TypeError): args = {} - tcid = tc.get("id") if tcid: call_details[tcid] = { "tool": fn_name, @@ -297,6 +300,9 @@ def summarize_background_review_actions( "target": args.get("target", "memory"), "content": args.get("content", ""), "old_text": args.get("old_text", ""), + "name": args.get("name", ""), + "old_string": args.get("old_string", ""), + "new_string": args.get("new_string", ""), } actions: List[str] = [] @@ -310,7 +316,7 @@ def summarize_background_review_actions( content_str = msg.get("content") if isinstance(content_str, str) and content_str in existing_tool_contents: continue - if tcid and call_details and tcid not in call_details: + if tcid and all_tool_call_ids and tcid not in call_details: continue try: data = json.loads(msg.get("content", "{}")) @@ -319,10 +325,22 @@ def summarize_background_review_actions( if not isinstance(data, dict) or not data.get("success"): continue message = data.get("message", "") - target = data.get("target", "") detail = call_details.get(tcid, {}) + target = data.get("target", "") or detail.get("target", "") is_skill = detail.get("tool") == "skill_manage" + message_lower = message.lower() + if not verbose: + if "created" in message_lower: + actions.append(message) + continue + if "updated" in message_lower: + actions.append(message) + continue + if is_skill and "patched" in message_lower: + actions.append(message) + continue + if is_skill: label = "Skill" elif target: @@ -334,9 +352,30 @@ def summarize_background_review_actions( action = detail.get("action", "") content = detail.get("content", "") old_text = detail.get("old_text", "") + skill_name = detail.get("name", "") max_preview = 120 if is_skill: - actions.append(f"📝 {message}" if message else f"Skill {action}") + change = data.get("_change", {}) + old_string = change.get("old", "") or detail.get("old_string", "") + new_string = change.get("new", "") or detail.get("new_string", "") + description = change.get("description", "") + if action == "patch" and (old_string or new_string): + old_preview = old_string[:80].replace("\n", " ") + ( + "…" if len(old_string) > 80 else "" + ) + new_preview = new_string[:80].replace("\n", " ") + ( + "…" if len(new_string) > 80 else "" + ) + actions.append( + f"📝 Skill '{skill_name}' patched: " + f"\"{old_preview}\" → \"{new_preview}\"" + ) + elif action == "create" and description: + actions.append(f"📝 Skill '{skill_name}' created: {description}") + elif action == "edit" and description: + actions.append(f"📝 Skill '{skill_name}' rewritten: {description}") + else: + actions.append(f"📝 {message}" if message else f"Skill {action}") elif action == "add" and content: preview = content[:max_preview] + ("…" if len(content) > max_preview else "") actions.append(f"{label} ➕ {preview}") @@ -348,14 +387,10 @@ def summarize_background_review_actions( actions.append(f"{label} ➖ {preview}") else: actions.append(f"{label} updated") - elif "created" in message.lower(): - actions.append(message) - elif "updated" in message.lower(): - actions.append(message) elif ( - "added" in message.lower() - or "replaced" in message.lower() - or "removed" in message.lower() + "added" in message_lower + or "replaced" in message_lower + or "removed" in message_lower or (target and "add" in message.lower()) or "Entry added" in message ): diff --git a/run_agent.py b/run_agent.py index a97f6c9c0a..94d3be3e67 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1411,10 +1411,15 @@ class AIAgent: def _summarize_background_review_actions( review_messages: List[Dict], prior_snapshot: List[Dict], + notification_mode: str = "on", ) -> List[str]: """Forwarder — see ``agent.background_review.summarize_background_review_actions``.""" from agent.background_review import summarize_background_review_actions - return summarize_background_review_actions(review_messages, prior_snapshot) + return summarize_background_review_actions( + review_messages, + prior_snapshot, + notification_mode=notification_mode, + ) def _spawn_background_review( self, diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py index f4b0faff7f..b512497c1c 100644 --- a/tests/run_agent/test_background_review.py +++ b/tests/run_agent/test_background_review.py @@ -115,10 +115,11 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon # must have snapshot them before this runs. self._session_messages = [] - def fake_summarize(review_messages, prior_snapshot): + def fake_summarize(review_messages, prior_snapshot, notification_mode="on"): events.append("summarize") captured["review_messages"] = list(review_messages) captured["prior_snapshot"] = list(prior_snapshot) + captured["notification_mode"] = notification_mode return [] monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent) @@ -146,6 +147,7 @@ def test_background_review_summarizer_receives_captured_messages_after_close(mon ] assert captured["review_messages"] == [review_tool_message] assert captured["prior_snapshot"] == messages_snapshot + assert captured["notification_mode"] == "on" def test_background_review_installs_auto_deny_approval_callback(monkeypatch): diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 020c3a0155..e3f48b2b6e 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -598,11 +598,22 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An shutil.rmtree(skill_dir, ignore_errors=True) return {"success": False, "error": scan_error} + # Extract description from frontmatter for verbose notifications + _desc = "" + try: + _fm_end = re.search(r'\n---\s*\n', content[3:]) + if _fm_end: + _parsed = yaml.safe_load(content[3:_fm_end.start() + 3]) + _desc = str(_parsed.get("description", ""))[:120] + except Exception: + pass + result = { "success": True, "message": f"Skill '{name}' created.", "path": str(skill_dir.relative_to(SKILLS_DIR)), "skill_md": str(skill_md), + "_change": {"description": _desc}, } if category: result["category"] = category @@ -639,10 +650,21 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: _atomic_write_text(skill_md, original_content) return {"success": False, "error": scan_error} + # Extract description from new content for verbose notifications + _desc = "" + try: + _fm_end = re.search(r'\n---\s*\n', content[3:]) + if _fm_end: + _parsed = yaml.safe_load(content[3:_fm_end.start() + 3]) + _desc = str(_parsed.get("description", ""))[:120] + except Exception: + pass + return { "success": True, - "message": f"Skill '{name}' updated.", + "message": f"Skill '{name}' updated (full rewrite).", "path": str(existing["path"]), + "_change": {"description": _desc}, } @@ -734,10 +756,16 @@ def _patch_skill( _atomic_write_text(target, original_content) return {"success": False, "error": scan_error} - return { + result = { "success": True, "message": f"Patched {'SKILL.md' if not file_path else file_path} in skill '{name}' ({match_count} replacement{'s' if match_count > 1 else ''}).", } + # Include change previews for verbose notifications + result["_change"] = { + "old": old_string[:200] + ("…" if len(old_string) > 200 else ""), + "new": new_string[:200] + ("…" if len(new_string) > 200 else ""), + } + return result def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, Any]: From 98ae28657fd7f0a86f3255024b3f448c9d77937d Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:33:56 -0700 Subject: [PATCH 86/92] feat(display): document and test memory_notifications setting Follow-up to salvaged PR #4684: - Add display.memory_notifications to DEFAULT_CONFIG (off|on|verbose, default on) - Document the setting in docs/user-guide/features/memory.md - Add resolver tests for off/on/verbose memory + skill paths --- hermes_cli/config.py | 6 ++ tests/run_agent/test_background_review.py | 114 +++++++++++++++++++++ website/docs/user-guide/features/memory.md | 21 ++++ 3 files changed, 141 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f374055eac..f2ee3ea48a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = { "tui_agents_nudge": True, "bell_on_complete": False, "show_reasoning": False, + # Background self-improvement review notifications surfaced in chat. + # "off" — no chat notification (the review still runs and writes) + # "on" — generic "💾 Memory updated" line (default) + # "verbose" — include a compact content preview of what changed + # Per-platform overrides via display.platforms..memory_notifications. + "memory_notifications": "on", "streaming": False, "timestamps": False, # Show [HH:MM] on user and assistant labels "final_response_markdown": "strip", # render | strip | raw diff --git a/tests/run_agent/test_background_review.py b/tests/run_agent/test_background_review.py index b512497c1c..8bce7e1507 100644 --- a/tests/run_agent/test_background_review.py +++ b/tests/run_agent/test_background_review.py @@ -315,3 +315,117 @@ def test_background_review_fork_skips_external_memory_plugins(monkeypatch): "the fork leaks harness prompts into the user's real memory " "namespace via on_turn_start / prefetch_all / sync_all." ) + + +# --------------------------------------------------------------------------- +# memory_notifications mode: off | on | verbose +# --------------------------------------------------------------------------- + +import json as _json + +from agent.background_review import summarize_background_review_actions + + +def _memory_add_review(): + """A minimal review transcript: one memory add (assistant call + tool result).""" + return [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_mem1", + "function": { + "name": "memory", + "arguments": _json.dumps( + { + "action": "add", + "target": "memory", + "content": "User prefers terse replies", + } + ), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_mem1", + "content": _json.dumps( + {"success": True, "message": "Entry added.", "target": "memory"} + ), + }, + ] + + +def _skill_patch_review(): + return [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_skill1", + "function": { + "name": "skill_manage", + "arguments": _json.dumps( + {"action": "patch", "name": "demo", "old_string": "a", "new_string": "b"} + ), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_skill1", + "content": _json.dumps( + { + "success": True, + "message": "Patched SKILL.md in skill 'demo' (1 replacement).", + "_change": {"old": "a", "new": "b"}, + } + ), + }, + ] + + +def test_memory_notifications_off_returns_nothing(): + actions = summarize_background_review_actions( + _memory_add_review(), [], notification_mode="off" + ) + assert actions == [] + + +def test_memory_notifications_on_returns_generic_line(): + actions = summarize_background_review_actions( + _memory_add_review(), [], notification_mode="on" + ) + assert actions == ["Memory updated"] + + +def test_memory_notifications_verbose_includes_content_preview(): + actions = summarize_background_review_actions( + _memory_add_review(), [], notification_mode="verbose" + ) + assert len(actions) == 1 + # Verbose surfaces the actual content that was saved. + assert "User prefers terse replies" in actions[0] + assert actions[0] != "Memory updated" + + +def test_memory_notifications_default_is_on(): + """No mode passed → behaves like 'on' (generic line, not empty/verbose).""" + actions = summarize_background_review_actions(_memory_add_review(), []) + assert actions == ["Memory updated"] + + +def test_skill_patch_off_silent_verbose_shows_diff(): + assert ( + summarize_background_review_actions( + _skill_patch_review(), [], notification_mode="off" + ) + == [] + ) + verbose = summarize_background_review_actions( + _skill_patch_review(), [], notification_mode="verbose" + ) + assert len(verbose) == 1 + assert "demo" in verbose[0] and "→" in verbose[0] diff --git a/website/docs/user-guide/features/memory.md b/website/docs/user-guide/features/memory.md index 1f0ee16942..91874c73e0 100644 --- a/website/docs/user-guide/features/memory.md +++ b/website/docs/user-guide/features/memory.md @@ -245,6 +245,27 @@ This is the answer to "the agent saved a wrong assumption about me": set `write_approval: true`, and every save — especially the unprompted background ones — waits for your yes/no before it ever enters your profile. +## Background review notifications (`display.memory_notifications`) + +After a turn, the background self-improvement review may quietly save a memory +or update a skill. By default it surfaces a short `💾 Memory updated` line in +chat so you know it happened. Control how chatty that is: + +```yaml +display: + memory_notifications: on # off | on (default) | verbose +``` + +| Value | Behaviour | +|-------|-----------| +| `off` | No chat notification. The review still runs and still writes — you just don't see a line for it. | +| `on` (default) | Generic line, e.g. `💾 Memory updated`, `💾 Skill 'foo' patched`. | +| `verbose` | Includes a compact preview of what changed, e.g. `💾 Memory ➕ User prefers terse replies` or a `"old" → "new"` skill diff snippet. | + +> This only governs the **gateway** chat notification. The review itself, and +> writes to your memory/skill stores, are unaffected by this setting. Set it +> per-platform via `display.platforms..memory_notifications`. + ## Controlling skill writes (`skills.write_approval`) Skills use the same on/off gate, but the review UX differs because a From fc956b9db6efeb889cad27a8b6552130aa51bc12 Mon Sep 17 00:00:00 2001 From: Wolfram Ravenwolf Date: Tue, 14 Apr 2026 17:15:57 +0200 Subject: [PATCH 87/92] feat: add tool_progress_style config (accumulate vs separate) Add display.tool_progress_style setting to control how tool progress messages are displayed in chat platforms: - 'accumulate' (default): Edit a single message with all tool calls (new v0.9.0 behavior) - 'separate': Send each tool call as its own message, interleaved with thinking messages (pre-v0.9 behavior, better readability) The setting participates in the per-platform display override system and can be set globally or per-platform. Files: gateway/display_config.py, gateway/run.py --- gateway/display_config.py | 4 ++++ gateway/run.py | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index 5daab9f23c..4d9daceeac 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -32,6 +32,7 @@ from typing import Any _GLOBAL_DEFAULTS: dict[str, Any] = { "tool_progress": "all", + "tool_progress_style": "accumulate", # "accumulate" = edit single msg; "separate" = one msg per tool "show_reasoning": False, "tool_preview_length": 0, "streaming": None, # None = follow top-level streaming config @@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any: if isinstance(value, str): return value.lower() in {"true", "1", "yes", "on"} return bool(value) + if setting == "tool_progress_style": + val = str(value).lower() + return val if val in ("accumulate", "separate") else "accumulate" if setting == "tool_preview_length": try: return int(value) diff --git a/gateway/run.py b/gateway/run.py index 2c8f14008d..687f898437 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13624,6 +13624,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _env_tp and not _tool_progress_configured else (_resolved_tp or _env_tp or "all") ) + # Tool progress style: "accumulate" (edit single msg) or "separate" (one msg per tool) + progress_style = resolve_display_setting(user_config, platform_key, "tool_progress_style") or "accumulate" # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. from gateway.config import Platform @@ -13930,7 +13932,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble progress_msg_id = None # ID of the current progress message to edit - can_edit = True # False once an edit fails (platform doesn't support it) + can_edit = progress_style != "separate" # "separate" = one message per tool (pre-v0.9 behavior) _last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control _PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits From 6373aba80fdc79c6f7468a6ed3880f08973f8448 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:39:15 -0700 Subject: [PATCH 88/92] feat(gateway): rename to tool_progress_grouping, add config/docs/tests Follow-up to salvaged PR #41620: - Rename tool_progress_style -> tool_progress_grouping (clearer intent) - Add display.tool_progress_grouping to DEFAULT_CONFIG (accumulate default) - Document in messaging docs incl. 'separate is noisier, only where progress enabled' - Add resolver tests (default/global/override/invalid/case) --- gateway/display_config.py | 4 +- gateway/run.py | 6 +-- hermes_cli/config.py | 6 +++ tests/gateway/test_display_config.py | 60 ++++++++++++++++++++++ website/docs/user-guide/messaging/index.md | 5 ++ 5 files changed, 76 insertions(+), 5 deletions(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index 4d9daceeac..58226ed48f 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -32,7 +32,7 @@ from typing import Any _GLOBAL_DEFAULTS: dict[str, Any] = { "tool_progress": "all", - "tool_progress_style": "accumulate", # "accumulate" = edit single msg; "separate" = one msg per tool + "tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool "show_reasoning": False, "tool_preview_length": 0, "streaming": None, # None = follow top-level streaming config @@ -239,7 +239,7 @@ def _normalise(setting: str, value: Any) -> Any: if isinstance(value, str): return value.lower() in {"true", "1", "yes", "on"} return bool(value) - if setting == "tool_progress_style": + if setting == "tool_progress_grouping": val = str(value).lower() return val if val in ("accumulate", "separate") else "accumulate" if setting == "tool_preview_length": diff --git a/gateway/run.py b/gateway/run.py index 687f898437..470d71906c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13624,8 +13624,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _env_tp and not _tool_progress_configured else (_resolved_tp or _env_tp or "all") ) - # Tool progress style: "accumulate" (edit single msg) or "separate" (one msg per tool) - progress_style = resolve_display_setting(user_config, platform_key, "tool_progress_style") or "accumulate" + # Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool) + progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate" # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. from gateway.config import Platform @@ -13932,7 +13932,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble progress_msg_id = None # ID of the current progress message to edit - can_edit = progress_style != "separate" # "separate" = one message per tool (pre-v0.9 behavior) + can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior) _last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control _PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f2ee3ea48a..4f801e2e9b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1485,6 +1485,12 @@ DEFAULT_CONFIG = { "tool_progress_command": False, # Enable /verbose command in messaging gateway "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) + # How gateway tool-progress is grouped on platforms that support message + # editing: "accumulate" (default) edits one bubble in place; "separate" + # sends one message per tool (the pre-v0.9 behavior, noisier). Only + # applies where tool_progress is already enabled. Per-platform override + # via display.platforms..tool_progress_grouping. + "tool_progress_grouping": "accumulate", # Auto-delete system-notice replies (e.g. "✨ New session started!", # "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms # that support message deletion (currently Telegram; other platforms diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 3f29592828..0678740755 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -450,3 +450,63 @@ class TestCleanupProgress: } } assert resolve_display_setting(config, "telegram", "cleanup_progress") is True, val + + +class TestToolProgressGrouping: + """resolve_display_setting() for the tool_progress_grouping knob.""" + + def test_default_is_accumulate(self): + """No config anywhere → global default 'accumulate'.""" + from gateway.display_config import resolve_display_setting + + assert ( + resolve_display_setting({}, "telegram", "tool_progress_grouping") + == "accumulate" + ) + + def test_global_separate(self): + from gateway.display_config import resolve_display_setting + + config = {"display": {"tool_progress_grouping": "separate"}} + assert ( + resolve_display_setting(config, "discord", "tool_progress_grouping") + == "separate" + ) + + def test_platform_override_wins(self): + from gateway.display_config import resolve_display_setting + + config = { + "display": { + "tool_progress_grouping": "accumulate", + "platforms": {"discord": {"tool_progress_grouping": "separate"}}, + } + } + assert ( + resolve_display_setting(config, "discord", "tool_progress_grouping") + == "separate" + ) + # Other platforms still get the global value. + assert ( + resolve_display_setting(config, "telegram", "tool_progress_grouping") + == "accumulate" + ) + + def test_invalid_value_falls_back_to_accumulate(self): + """_normalise rejects anything outside accumulate|separate.""" + from gateway.display_config import resolve_display_setting + + config = {"display": {"tool_progress_grouping": "bogus"}} + assert ( + resolve_display_setting(config, "telegram", "tool_progress_grouping") + == "accumulate" + ) + + def test_case_insensitive(self): + from gateway.display_config import resolve_display_setting + + config = {"display": {"tool_progress_grouping": "SEPARATE"}} + assert ( + resolve_display_setting(config, "telegram", "tool_progress_grouping") + == "separate" + ) diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index d0129be29b..ce61e73488 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -320,6 +320,11 @@ Control how much tool activity is displayed in `~/.hermes/config.yaml`: display: tool_progress: all # off | new | all | verbose tool_progress_command: false # set to true to enable /verbose in messaging + # How progress is grouped on platforms that support message editing: + # accumulate (default) — edit one bubble in place as tools run + # separate — send one message per tool (pre-v0.9 style; noisier) + # Only applies where tool_progress is already enabled. + tool_progress_grouping: accumulate # accumulate | separate ``` When enabled, the bot sends status messages as it works: From 9a59ad73ddf18c487a85089308417c0bc97a9b9f Mon Sep 17 00:00:00 2001 From: MrDiamondBallz <264773240+MrDiamondBallz@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:10:55 -0700 Subject: [PATCH 89/92] fix(auth): preserve Codex pool-only rate-limit state Classify exhausted pool-only openai-codex credentials as quota/rate-limited instead of missing auth. This prevents auth status and runtime credential resolution from reporting missing credentials when a valid manual:device_code pool credential exists but is temporarily in a 429 usage-limit cooldown. Adds regression coverage for pool-only Codex auth status and runtime resolution. --- hermes_cli/auth.py | 109 +++++++++++++++++++++++++ tests/hermes_cli/test_auth_commands.py | 70 ++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 452723a3df..590b6794d2 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3806,6 +3806,26 @@ def resolve_codex_runtime_credentials( "last_refresh": None, "auth_mode": "chatgpt", } + pool_rate_limit = _codex_pool_rate_limit_status() + if pool_rate_limit: + reset_at = pool_rate_limit.get("reset_at") + if isinstance(reset_at, (int, float)) and reset_at > time.time(): + remaining = int(reset_at - time.time()) + message = ( + f"Codex provider quota exhausted (429); retry after {remaining}s. " + "Credentials are still valid." + ) + else: + message = ( + "Codex provider quota exhausted (429). Credentials are still valid; " + "retry after the usage limit resets." + ) + raise AuthError( + message, + provider="openai-codex", + code=CODEX_RATE_LIMITED_CODE, + relogin_required=False, + ) if read_error is not None: raise read_error raise AuthError( @@ -3852,6 +3872,79 @@ def resolve_codex_runtime_credentials( } +def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]: + """Return metadata for a pool-only Codex credential in quota cooldown.""" + def _parse_reset_at(value: Any) -> Optional[float]: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + numeric = float(value) + if numeric <= 0: + return None + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + numeric = float(raw) + except ValueError: + numeric = None + if numeric is not None: + return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + pool = auth_store.get("credential_pool") + if not isinstance(pool, dict): + return None + entries = pool.get("openai-codex") + if not isinstance(entries, list): + return None + now = time.time() + for entry in entries: + if not isinstance(entry, dict): + continue + token = entry.get("access_token") + if not isinstance(token, str) or not token.strip(): + continue + if entry.get("last_status") != "exhausted": + continue + code = entry.get("last_error_code") + reason = str(entry.get("last_error_reason") or "").lower() + message = str(entry.get("last_error_message") or "").lower() + is_rate_limited = ( + code == 429 + or "rate_limit" in reason + or "usage_limit" in reason + or "quota" in reason + or "rate limit" in message + or "usage limit" in message + or "quota" in message + ) + if not is_rate_limited: + continue + reset_at = _parse_reset_at(entry.get("last_error_reset_at")) + if reset_at is not None and reset_at <= now: + continue + return { + "label": entry.get("label"), + "last_refresh": entry.get("last_refresh"), + "reset_at": reset_at, + "reason": entry.get("last_error_reason"), + "message": entry.get("last_error_message"), + } + except Exception: + logger.debug("Codex pool rate-limit lookup failed", exc_info=True) + return None + + def _pool_codex_access_token() -> str: """Return the most-recent usable access_token from the openai-codex pool. @@ -5907,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]: "source": f"pool:{getattr(entry, 'label', 'unknown')}", "api_key": api_key, } + rate_limit = _codex_pool_rate_limit_status() + if rate_limit: + return { + "logged_in": True, + "auth_store": str(_auth_file_path()), + "last_refresh": rate_limit.get("last_refresh"), + "auth_mode": "chatgpt", + "source": f"pool:{rate_limit.get('label') or 'unknown'}", + "rate_limited": True, + "error_code": CODEX_RATE_LIMITED_CODE, + "error": ( + rate_limit.get("message") + or "Codex provider quota exhausted; retry after the usage limit resets." + ), + "reset_at": rate_limit.get("reset_at"), + } except Exception: pass diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 1723c11e32..949a936962 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -4,6 +4,7 @@ from __future__ import annotations import base64 import json +import time from datetime import datetime, timezone from unittest.mock import patch @@ -25,6 +26,37 @@ def _jwt_with_email(email: str) -> str: return f"{header}.{payload}.signature" +def _codex_pool_only_store(*, exhausted: bool = False) -> dict: + entry = { + "id": "codex-1", + "label": "codex@example.com", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": _jwt_with_email("codex@example.com"), + "refresh_token": "refresh-token", + "base_url": "https://chatgpt.com/backend-api/codex", + "last_refresh": "2026-06-15T10:00:00Z", + } + if exhausted: + entry.update( + { + "last_status": "exhausted", + "last_status_at": time.time(), + "last_error_code": 429, + "last_error_reason": "usage_limit_reached", + "last_error_message": "The usage limit has been reached", + "last_error_reset_at": time.time() + 3600, + } + ) + return { + "version": 1, + "active_provider": "openai-codex", + "providers": {}, + "credential_pool": {"openai-codex": [entry]}, + } + + @pytest.fixture(autouse=True) def _clear_provider_env(monkeypatch): for key in ( @@ -483,6 +515,44 @@ def test_auth_add_codex_oauth_keeps_distinct_pool_accounts(tmp_path, monkeypatch assert payload["active_provider"] == "openai-codex" +def test_codex_auth_status_reports_pool_only_credential(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, _codex_pool_only_store()) + + from hermes_cli.auth import get_codex_auth_status + + status = get_codex_auth_status() + + assert status["logged_in"] is True + assert status["source"] == "pool:codex@example.com" + + +def test_codex_auth_status_reports_pool_only_rate_limit(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True)) + + from hermes_cli.auth import get_codex_auth_status + + status = get_codex_auth_status() + + assert status["logged_in"] is True + assert status["rate_limited"] is True + assert status["error_code"] == "codex_rate_limited" + + +def test_codex_runtime_pool_only_rate_limit_is_not_missing_auth(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, _codex_pool_only_store(exhausted=True)) + + from hermes_cli.auth import AuthError, CODEX_RATE_LIMITED_CODE, resolve_codex_runtime_credentials + + with pytest.raises(AuthError) as exc_info: + resolve_codex_runtime_credentials() + + assert exc_info.value.code == CODEX_RATE_LIMITED_CODE + assert exc_info.value.relogin_required is False + + def test_auth_add_xai_oauth_sets_active_provider(tmp_path, monkeypatch): """hermes auth add xai-oauth must write providers singleton and set active_provider. From 1ac76a9472778a755b022c2d861eec3b8ef8ad25 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:26:10 -0700 Subject: [PATCH 90/92] chore: add MrDiamondBallz to release AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index cdebc8e10a..3f2823f03d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -49,6 +49,7 @@ AUTHOR_MAP = { "rio.jeong@thebytesize.ai": "rio-jeong", "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", + "264773240+MrDiamondBallz@users.noreply.github.com": "MrDiamondBallz", "kenmege@yahoo.com": "Kenmege", "tianying.x@eukarya.io": "xtymac", "dkobi16@gmail.com": "Diyoncrz18", From 2483200963e43e7335e02f3f51440db089bcc1a3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 05:56:50 -0700 Subject: [PATCH 91/92] test(tui): isolate session-create no-race test from shard-sibling leakage (#47230) test_session_create_no_race_keeps_worker_alive flaked on CI shard 3 with 'build thread unregistered its own notify despite no race' while passing 20/20 in isolation locally. Root cause: daemon build threads from sibling session.create tests in the same shard process mutate the shared server._sessions dict under _sessions_lock and can replace/pop entries mid-run, flipping this build thread's 'replaced' check (server.py:1011) to True and triggering a spurious unregister_gateway_notify. Fix is test-only: snapshot + clear server._sessions before the request so the test sees only its own session, restore siblings in finally. Also assert agent_ready.wait() actually returned True (was silently ignoring timeout) and bump the timeout 2s -> 10s for loaded CI runners. --- tests/test_tui_gateway_server.py | 58 +++++++++++++++++++------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 2b37b5788b..956385dc51 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -4901,33 +4901,45 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): ) monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None) - resp = server.handle_request( - { - "id": "1", - "method": "session.create", - "params": {"cols": 80}, - } - ) - sid = resp["result"]["session_id"] + # Isolate from sibling-test leakage: daemon build threads from prior + # session.create tests in the same shard process mutate the shared + # ``server._sessions`` dict under ``_sessions_lock`` and can replace/pop + # entries mid-run, which would flip this build thread's ``replaced`` check + # to True and trigger a spurious unregister. Snapshot, clear, and restore + # so this test sees only its own session regardless of shard composition. + _saved_sessions = dict(server._sessions) + server._sessions.clear() - # Wait for the build to finish (ready event inside session dict). - session = server._sessions[sid] - session["agent_ready"].wait(timeout=2.0) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.create", + "params": {"cols": 80}, + } + ) + sid = resp["result"]["session_id"] - # Build finished without a close race — nothing should have been - # cleaned up by the orphan check. - assert ( - closed_workers == [] - ), f"build thread closed its own worker despite no race: {closed_workers}" - assert ( - unregistered_keys == [] - ), f"build thread unregistered its own notify despite no race: {unregistered_keys}" + # Wait for the build to finish (ready event inside session dict). + session = server._sessions[sid] + built = session["agent_ready"].wait(timeout=10.0) + assert built, "agent build did not complete within timeout" - # Session should have the live worker installed. - assert session.get("slash_worker") is not None + # Build finished without a close race — nothing should have been + # cleaned up by the orphan check. + assert ( + closed_workers == [] + ), f"build thread closed its own worker despite no race: {closed_workers}" + assert ( + unregistered_keys == [] + ), f"build thread unregistered its own notify despite no race: {unregistered_keys}" - # Cleanup - server._sessions.pop(sid, None) + # Session should have the live worker installed. + assert session.get("slash_worker") is not None + finally: + # Cleanup + restore sibling sessions we snapshotted. + server._sessions.clear() + server._sessions.update(_saved_sessions) def test_get_db_degrades_cleanly_when_sessiondb_init_fails(monkeypatch): From 4d470b3dbb881f31792e5f66b3f5d841bb6d469f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 06:20:01 -0700 Subject: [PATCH 92/92] fix(slack): route /debug via /hermes to restore Telegram-parity (#47248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack caps apps at 50 slash commands and the registry is at that ceiling, so adding /debug clamped it out of the native list and broke the telegram-parity test (debug on Telegram, absent from Slack native slashes, in neither exclusion set). Add 'debug' to _SLACK_VIA_HERMES_ONLY — same treatment credits already gets. /debug stays native on CLI/TUI/Telegram/Discord and reachable via /hermes debug on Slack. --- hermes_cli/commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 576eefbf0b..a1e20dabc0 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. # - credits: the billing/top-up surface; reached via /hermes credits on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits"}) +# - debug: the log/report upload surface; reached via /hermes debug on Slack. +_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"}) def _sanitize_slack_name(raw: str) -> str: