# NousResearch/hermes-agent · Offload blocking profiles endpoints from asyncio loop

## Open now

- 1 issue, 1 PR open on GitHub.
- #54523: \[Bug\]: Remote desktop over Tailscale: async routes block the asyncio loop 10-25s (list_profiles, /api/profiles/sessions) starving the WS; plus Chromium LNA + Electron net stalls
- PR #140923: fix(dashboard): verify session tokens off the serve event loop

A bug was identified where profiles-related endpoints (list_profiles, /api/profiles/sessions) block the asyncio event loop for 10–25 seconds, starving WebSocket connections. These members address that problem through event-loop offloading, a single-pass alias map, and a skill-count cache.

## TL;DR

- 5 member(s): 1 open issue(s), 0 closed issue(s), 1 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #54770. Verify it addresses your case.
- **Recommended:** read PR #54770 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #54523: \[Bug\]: Remote desktop over Tailscale: async routes block the asyncio loop 10-25s (list_profiles, /api/profiles/sessions) starving the WS; plus Chromium LNA + Electron net stalls

## Prior & competing PRs

- **#54560: fix: offload blocking profiles endpoints from asyncio event loop (#54523)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `merged_via`; implication `superseded`; refs `1bb7b59c5de4eee89a9f47f93a48471ba4fd6605`, `09f10e2b77e082667911e7c6a42d607085271ebb`, `f64e4f4f5`, `#54523`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#54770: perf(profiles): single-pass list_profiles alias map + skill-count cache + event-loop offload**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/profiles.py`, `hermes_cli/web_server.py`, `infographic/list-profiles-perf-54751/infographic.png`, `scripts/release.py`
  - fix hunks:
  - `hermes_cli/profiles.py`

    ```diff
    @@ -27,6 +27,7 @@
     import stat
     import subprocess
     import sys
    +import time
     from dataclasses import dataclass
     from pathlib import Path, PurePosixPath, PureWindowsPath
     from typing import List, Optional, Tuple
    ```

    ```diff
    @@ -528,16 +529,40 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
         A custom alias (name != profile) is preferred over the profile-named wrapper
         so ``profile list``/``show`` surface the command the user actually typed.
         Results are sorted for deterministic output when several aliases match.
    +
    +    For listing ALL profiles at once, prefer :func:`build_alias_map` — calling
    +    this per-profile re-reads every wrapper file N times (O(N*M)); on a wrapper
    +    dir like ``~/.local/bin`` that also holds large unrelated binaries (ffmpeg
    +    etc.) that meant multi-second ``list_profiles`` latency and desktop timeouts.
    +    """
    +    return build_alias_map().get(normalize_profile_name(profile_name))
    +
    +
    +# Cap how much of a wrapper file we read when reverse-looking-up its profile.
    +# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``)
    +# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds
    +# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was
    +# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and
    +# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass.
    +_WRAPPER_READ_LIMIT = 8192
    +
    +
    +def build_alias_map() -> dict[str, str]:
    +    """Single-pass reverse map ``{canonical_profile -> alias_name}``.
    +
    +    Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile)
    +    and reads only a small head slice of each candidate wrapper, skipping
    +    binaries. A custom alias (file name != profile) wins over the profile-named
    +    wrapper, matching ``find_alias_for_profile``'s preference; deterministic via
    +    sorted iteration.
         """
         wrapper_dir = _get_wrapper_dir()
    +    result: dict[str, str] = {}
         if not wrapper_dir.is_dir():
    -        return None
    -    canon = normalize_profile_name(profile_name)
    +        return result
         is_windows = sys.platform == "win32"
    -    needle = f"hermes -p {canon}"
    +    prefix = "hermes -p "
     
    -    custom: Optional[str] = None
    -    profile_named: Optional[str] = None
         for entry in sorted(wrapper_dir.iterdir()):
             if not entry.is_file():
                 continue
    ```

    ```diff
    @@ -547,17 +572,28 @@ def find_alias_for_profile(profile_name: str) -> Optional[str]:
             if not is_windows and entry.suffix:
                 continue
             try:
    -            content = entry.read_text()
    +            with open(entry, "r", encoding="utf-8", errors="strict") as f:
    +                content = f.read(_WRAPPER_READ_LIMIT)
             except (OSError, UnicodeDecodeError):
    +            # UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
    +            continue
    +        idx = content.find(prefix)
    +        if idx == -1:
                 continue
    -        if needle not in content:
    +        rest = content[idx + len(prefix):]
    +        # Profile id is the first whitespace-delimited token after the flag.
    +        canon = rest.split(None, 1)[0].strip() if rest.strip() else ""
    +        if not canon:
                 continue
    +        canon = normalize_profile_name(canon)
             alias = entry.stem if is_windows else entry.name
    +        # Custom alias (name != profile) preferred; otherwise keep the
    +        # profile-named wrapper. Don't overwrite a custom alias already found.
             if alias == canon:
    -            profile_named = alias
    -        elif custom is None:
    -            custom = alias
    -    return custom if custom is not None else profile_named
    +            result.setdefault(canon, alias)
    +        else:
    +            result[canon] = alias
    +    return result
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -674,16 +710,68 @@ def _check_gateway_running(profile_dir: Path) -> bool:
             return False
     
     
    +# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")``
    +# recurses the entire skill tree (each skill carries references/scripts/assets
    +# sub-trees); the default profile alone has ~270 skills, and ``list_profiles``
    +# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long
    +# enough that the desktop's per-request backend calls time out and the sidebar
    +# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated
    +# when the dir tree's signature (skills_dir + immediate category dirs mtimes)
    +# changes (catches skill add/remove) or after a short TTL (catches deep edits).
    +_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {}
    +_SKILL_COUNT_TTL_SECONDS = 30.0
    +
    +
    +def _skills_dir_signature(skills_dir: Path) -> float:
    +    """Cheap change-signature for a skills tree.
    +
    +    Max mtime of ``skills_dir`` and its immediate children (category dirs).
    +    Adding/removing a category bumps ``skills_dir``'s mtime; adding/removing a
    +    skill inside a category bumps that category dir's mtime. One ``scandir``
    +    (not a recursive walk) keeps this O(#categories), not O(#files).
    +    """
    +    try:
    +        sig = skills_dir.stat().st_mtime
    +    except OSError:
    +        return 0.0
    +    try:
    +        with os.scandir(skills_dir) as it:
    +            for entry in it:
    +                try:
    +                    if entry.is_dir(follow_symlinks=False):
    +                        m = entry.stat(follow_symlinks=False).st_mtime
    +                        if m > sig:
    +                            sig = m
    +                except OSError:
    +                    continue
    +    except OSError:
    +        pass
    +    return sig
    +
    +
     def _count_skills(profile_dir: Path) -> int:
    -    """Count installed skills in a profile."""
    +    """Count installed skills in a profile (cached by skills-dir signature)."""
         skills_dir = profile_dir / "skills"
         if not skills_dir.is_dir():
             return 0
    +
    +    key = str(skills_dir)
    +    signature = _skills_dir_signature(skills_dir)
    +    now = time.time()
    +    cached = _SKILL_COUNT_CACHE.get(key)
    +    if (
    +        cached is not None
    +        and cached[0] == signature
    +        and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS
    +    ):
    +        return cached[2]
    +
         count = 0
         for md in skills_dir.rglob("SKILL.md"):
             if is_excluded_skill_path(md):
                 continue
             count += 1
    +    _SKILL_COUNT_CACHE[key] = (signature, now, count)
         return count
     
     
    ```

    ```diff
    @@ -797,6 +885,10 @@ def list_profiles() -> List[ProfileInfo]:
         # Named profiles
         profiles_root = _get_profiles_root()
         if profiles_root.is_dir():
    +        # Build the {profile -> alias} map ONCE here instead of calling
    +        # find_alias_for_profile() per profile (which re-scanned the whole
    +        # wrapper dir each time — O(N*M), the dominant cost in this function).
    +        alias_map = build_alias_map()
             for entry in sorted(profiles_root.iterdir()):
                 if not entry.is_dir():
                     continue
    ```

    ```diff
    @@ -806,7 +898,7 @@ def list_profiles() -> List[ProfileInfo]:
                 if not _PROFILE_ID_RE.match(name):
                     continue
                 model, provider = _read_config_model(entry)
    -            alias_name = find_alias_for_profile(name)
    +            alias_name = alias_map.get(normalize_profile_name(name))
                 if alias_name:
                     is_windows = sys.platform == "win32"
                     alias_path = wrapper_dir / (f"{alias_name}.bat" if is_windows else alias_name)
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -3430,7 +3430,7 @@ async def get_sessions(
     
     
     @app.get("/api/profiles/sessions")
    -async def get_profiles_sessions(
    +def get_profiles_sessions(
         limit: int = 20,
         offset: int = 0,
         min_messages: int = 0,
    ```

    ```diff
    @@ -10719,7 +10719,9 @@ def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:
     async def list_profiles_endpoint():
         from hermes_cli import profiles as profiles_mod
         try:
    -        return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]}
    +        loop = asyncio.get_running_loop()
    +        profiles = await loop.run_in_executor(None, profiles_mod.list_profiles)
    +        return {"profiles": [_profile_to_dict(p) for p in profiles]}
         except Exception:
             _log.exception("GET /api/profiles failed; falling back to profile directory scan")
             return {"profiles": _fallback_profile_dicts(profiles_mod)}
    ```
  - `scripts/release.py`

    ```diff
    @@ -1710,6 +1710,7 @@
         "[redacted:email]": "infinitycrew39",  # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
         "[redacted:email]": "huangxun375-stack",  # PR #37251 / #48894 structured OpenViking sync
         "218421507+Sahil-SS9@users.noreply.github.com": "Sahil-SS9",  # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill)
    +    "[redacted:email]": "max-chen",  # PR #51194 salvage (single-pass list_profiles alias map + skill-count cache; #54751)
         # v0.17.0 additions
         "[redacted:email]": "pengyuyanITYU",  # PR #43618 (harden local file tree paths)
         "[redacted:email]": "AIalliAI",  # PR #44159 (desktop hover-reveal inset)
    ```
- **#54751: perf(profiles): speed up list_profiles (single-pass alias map + skill-count cache + event-loop offload)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#54770`
- **#140923: fix(dashboard): verify session tokens off the serve event loop**. open
  - maintainer direction: category `silent_close`; implication `active`
  - files: `hermes_cli/dashboard_auth/middleware.py`, `tests/hermes_cli/test_dashboard_auth_gate_offloop.py`

## Fix status

- Merged to main in PR #54770 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-29`.
- Subsystem: `dashboard`
