Active profile not propagated to desktop/TUI subsystems

NousResearch/hermes-agent · cluster 40677 · apps/desktop

Data as of

Nothing open on GitHub. Fix merged in #60180. Check it covers your case.

The short answer

0open issues
0open PRs
15items grouped
Known?
Yes. 15 related items grouped here, 6 issues already closed.
Fix event
A fix was merged to main in PR #60180. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #60180, #73521. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple bugs and fixes around desktop, TUI, and dashboard components ignoring the active profile and falling back to global or default settings. Affected areas include TTS/voice config, audio endpoints, chat workers, skills resolution, and slash-command subprocesses.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #60180 fix(skills): profile-aware skills resolution in long-lived runtimes (#40677)

    merged to main

    files: tests/test_tui_gateway_server.py, tests/tools/test_skills_tool_profile_scope.py, tests/tui_gateway/test_slash_worker_profile_home.py, tools/skill_manager_tool.py, tools/skills_tool.py, tui_gateway/server.py

    Show the diff
    • tests/test_tui_gateway_server.py
      @@ -1947,7 +1947,7 @@ def test_init_session_fires_reset_hook(monkeypatch):
           hooks = []
       
           class _FakeWorker:
      -        def __init__(self, key, model):
      +        def __init__(self, key, model, profile_home=None):
                   self.key = key
       
               def close(self):
      @@ -5453,7 +5453,7 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch):
           unregistered_keys: list[str] = []
       
           class _FakeWorker:
      -        def __init__(self, key, model):
      +        def __init__(self, key, model, profile_home=None):
                   self.key = key
                   self._closed = False
       
      @@ -5574,7 +5574,7 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
           unregistered_keys: list[str] = []
       
           class _FakeWorker:
      -        def __init__(self, key, model):
      +        def __init__(self, key, model, profile_home=None):
                   self.key = key
       
               def close(self):
      @@ -5678,7 +5678,7 @@ def __init__(self):
       
       def test_session_create_continues_when_state_db_is_unavailable(monkeypatch):
           class _FakeWorker:
      -        def __init__(self, key, model):
      +        def __init__(self, key, model, profile_home=None):
                   self.key = key
       
               def close(self):
      @@ -5726,7 +5726,7 @@ def test_session_create_lazy_info_reports_desktop_contract(monkeypatch):
           date" on every launch even against a current backend."""
       
           class _FakeWorker:
      -        def __init__(self, key, model):
      +        def __init__(self, key, model, profile_home=None):
                   self.key = key
       
               def close(self):
    • tests/tools/test_skills_tool_profile_scope.py
      @@ -0,0 +1,105 @@
      +"""Regression tests for profile-scoped skills_tool path resolution."""
      +
      +import importlib
      +import json
      +from pathlib import Path
      +
      +
      +def _write_skill(root: Path, category: str, name: str, description: str) -> Path:
      +    skill_dir = root / "skills" / category / name
      +    skill_dir.mkdir(parents=True)
      +    (skill_dir / "SKILL.md").write_text(
      +        f"---\n"
      +        f"name: {name}\n"
      +        f"description: {description}\n"
      +        f"---\n\n"
      +        f"# {name}\n\n"
      +        f"Loaded from {description}.\n",
      +        encoding="utf-8",
      +    )
      +    return skill_dir
      +
      +
      +def _reload_skills_tool(import_home: Path, monkeypatch):
      +    monkeypatch.setenv("HERMES_HOME", str(import_home))
      +    import tools.skills_tool as skills_tool
      +
      +    return importlib.reload(skills_tool)
      +
      +
      +def test_skill_view_uses_live_profile_home_after_module_import(tmp_path, monkeypatch):
      +    """skill_view should not stay pinned to HERMES_HOME from import time."""
      +    default_home = tmp_path / "default-home"
      +    profile_home = tmp_path / "profiles" / "orchestrator"
      +    _write_skill(default_home, "autonomous-ai-agents", "default-only", "default home")
      +    profile_skill_dir = _write_skill(
      +        profile_home,
      +        "software-development",
      +        "kanban-orchestrator-operations",
      +        "orchestrator profile",
      +    )
      +
      +    skills_tool = _reload_skills_tool(default_home, monkeypatch)
      +    assert skills_tool.SKILLS_DIR == default_home / "skills"
      +
      +    monkeypatch.setenv("HERMES_HOME", str(profile_home))
      +
      +    result = json.loads(
      +        skills_tool.skill_view("kanban-orchestrator-operations", preprocess=False)
      +    )
      +
      +    assert result["success"] is True
      +    assert result["name"] == "kanban-orchestrator-operations"
      +    assert Path(result["skill_dir"]) == profile_skill_dir
      +    assert "orchestrator profile" in result["content"]
      +
      +
      +def test_skills_list_uses_live_profile_home_after_module_import(tmp_path, monkeypatch):
      +    """skills_list should list the active profile skills, not the import-time root."""
      +    default_home = tmp_path / "default-home"
      +    profile_home = tmp_path / "profiles" / "orchestrator"
      +    _write_skill(default_home, "autonomous-ai-agents", "default-only", "default home")
      +    _write_skill(
      +        profile_home,
      +        "software-development",
      +        "kanban-orchestrator-operations",
      +        "orchestrator profile",
      +    )
      +
      +    skills_tool = _reload_skills_tool(default_home, monkeypatch)
      +    monkeypatch.setenv("HERMES_HOME", str(profile_home))
      +
      +    result = json.loads(skills_tool.skills_list())
      +    names = {skill["name"] for skill in result["skills"]}
      +
      +    assert result["success"] is True
      +    assert "kanban-orchestrator-operations" in names
      +    assert "default-only" not in names
      +
      +
      +def test_explicit_skills_dir_monkeypatch_still_wins(tmp_path, monkeypatch):
      +    """Existing tests can still override tools.skills_tool.SKILLS_DIR directly."""
      +    default_home = tmp_path / "default-home"
      +    profile_home = tmp_path / "profiles" / "orchestrator"
      +    patched_root = tmp_path / "patched"
      +    patched_skill_dir = _write_skill(
      +        patched_root,
      +        "software-development",
      +        "patched-skill",
      +        "patched skills dir",
      +    )
      +    _write_skill(
      +        profile_home,
      +        "software-development",
      +        "profile-skill",
      +        "orchestrator profile",
      +    )
      +
      +    skills_tool = _reload_skills_tool(default_home, monkeypatch)
      +    monkeypatch.setenv("HERMES_HOME", str(profile_home))
      +    monkeypatch.setattr(skills_tool, "SKILLS_DIR", patched_root / "skills")
      +
      +    result = json.loads(skills_tool.skill_view("patched-skill", preprocess=False))
      +
      +    assert result["success"] is True
      +    assert Path(result["skill_dir"]) == patched_skill_dir
    • tests/tui_gateway/test_slash_worker_profile_home.py
      @@ -0,0 +1,124 @@
      +"""Tests for TUI gateway slash_worker profile_home propagation (#40677)."""
      +
      +import os
      +import subprocess
      +import sys
      +from unittest.mock import MagicMock, patch, call
      +
      +import pytest
      +
      +
      +def test_slash_worker_accepts_profile_home():
      +    """_SlashWorker.__init__ accepts profile_home parameter."""
      +    with patch.dict("sys.modules", {
      +        "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
      +    }):
      +        with patch("subprocess.Popen") as mock_popen:
      +            mock_popen.return_value.stdout = MagicMock()
      +            mock_popen.return_value.stderr = MagicMock()
      +            
      +            from tui_gateway.server import _SlashWorker
      +            
      +            # Test initialization with profile_home
      +            worker = _SlashWorker(
      +                session_key="test_key",
      +                model="test-model",
      +                profile_home="/home/luke/.hermes/profiles/work"
      +            )
      +            
      +            # Verify Popen was called
      +            assert mock_popen.called
      +            
      +            # Check that HERMES_HOME was set in the environment
      +            call_kwargs = mock_popen.call_args[1]
      +            assert "env" in call_kwargs
      +            assert call_kwargs["env"]["HERMES_HOME"] == "/home/luke/.hermes/profiles/work"
      +
      +
      +def test_slash_worker_without_profile_home():
      +    """_SlashWorker works without profile_home parameter (backward compatible)."""
      +    with patch.dict("sys.modules", {
      +        "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
      +    }):
      +        with patch("subprocess.Popen") as mock_popen:
      +            mock_popen.return_value.stdout = MagicMock()
      +            mock_popen.return_value.stderr = MagicMock()
      +            
      +            from tui_gateway.server import _SlashWorker
      +            
      +            # Test initialization without profile_home (backward compatible)
      +            worker = _SlashWorker(
      +                session_key="test_key",
      +                model="test-model"
      +            )
      +            
      +            # Verify Popen was called
      +            assert mock_popen.called
      +            
      +            # Check that HERMES_HOME was NOT overridden
      +            call_kwargs = mock_popen.call_args[1]
      +            assert "env" in call_kwargs
      +            # HERMES_HOME should be from parent env or undefined (inherited from os.environ)
      +            # The key is that it's not explicitly set when profile_home is None
      +            env = call_kwargs["env"]
      +            # Verify env is a copy of os.environ
      +            assert "PATH" in env
      +
      +
      +def test_slash_worker_with_none_profile_home():
      +    """_SlashWorker with explicit profile_home=None works."""
      +    with patch.dict("sys.modules", {
      +        "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
      +    }):
      +        with patch("subprocess.Popen") as mock_popen:
      +            mock_popen.return_value.stdout = MagicMock()
      +            mock_popen.return_value.stderr = MagicMock()
      +            
      +            from tui_gateway.server import _SlashWorker
      +            
      +            # Test initialization with explicit None
      +            worker = _SlashWorker(
      +                session_key="test_key",
      +                model="test-model",
      +                profile_home=None
      +            )
      +            
      +            # Verify Popen was called
      +            assert mock_popen.called
      +            
      +            # Check that HERMES_HOME was NOT set
      +            call_kwargs = mock_popen.call_args[1]
      +            env = call_kwargs["env"]
      +            # When profile_home is None, HERMES_HOME should come from parent env only
      +            if "HERMES_HOME" in env:
      +                # This is from os.environ at test time, not from our code
      +                pass
      +
      +
      +def test_slash_worker_inherits_argv_correctly():
      +    """_SlashWorker passes correct argv to Popen."""
      +    with patch.dict("sys.modules", {
      +        "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
      +    }):
      +        with patch("subprocess.Popen") as mock_popen:
      +            mock_popen.return_value.stdout = MagicMock()
      +            mock_popen.return_value.stderr = MagicMock()
      +            
      +            from tui_gateway.server import _SlashWorker
      +            
      +            # Test that argv is correct
      +            worker = _SlashWorker(
      +                session_key="my_session",
      +                model="gpt-4"
      +            )
      +            
      +            call_args = mock_popen.call_args[0][0]
      +            
      +            # Verify argv structure
      +            assert sys.executable in call_args
      +            assert "-m" in call_args
      +            assert "tui_gateway.slash_worker" in call_args
      +            assert "--session-key" in call_args
      +            assert "my_session" in call_args
      +            assert "--model" in call_args
      +            assert "gpt-4" in call_args
    • tools/skill_manager_tool.py
      @@ -150,6 +150,22 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:
       # All skills live in ~/.hermes/skills/ (single source of truth)
       HERMES_HOME = get_hermes_home()
       SKILLS_DIR = HERMES_HOME / "skills"
      +_SKILLS_DIR_AT_IMPORT = SKILLS_DIR
      +
      +
      +def _skills_dir() -> Path:
      +    """Return the active profile's skills directory at call time.
      +
      +    Long-lived multi-profile runtimes (Dashboard/TUI/Desktop backend, cron,
      +    kanban workers) import this module once under the launch HERMES_HOME and
      +    later bind a different profile per session (#40677). Honor an explicitly
      +    patched module-level ``SKILLS_DIR`` (tests), otherwise resolve from the
      +    live profile-scoped HERMES_HOME on every call.
      +    """
      +    configured = Path(SKILLS_DIR)
      +    if configured != _SKILLS_DIR_AT_IMPORT:
      +        return configured
      +    return get_hermes_home() / "skills"
       
       MAX_NAME_LENGTH = 64
       MAX_DESCRIPTION_LENGTH = 1024
      @@ -174,7 +190,7 @@ def _containing_skills_root(skill_path: Path) -> Path:
                   return root
               except (ValueError, OSError):
                   continue
      -    return SKILLS_DIR
      +    return _skills_dir()
       
       
       def _is_path_redirect(path: Path) -> bool:
      @@ -562,8 +578,8 @@ def _validate_content_size(content: str, label: str = "SKILL.md") -> Optional[st
       def _resolve_skill_dir(name: str, category: str = None) -> Path:
           """Build the directory path for a new skill, optionally under a category."""
           if category:
      -        return SKILLS_DIR / category / name
      -    return SKILLS_DIR / name
      +        return _skills_dir() / category / name
      +    return _skills_dir() / name
       
       
       def _find_skill(name: str) -> Optional[Dict[str, Any]]:
      @@ -608,8 +624,9 @@ def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]:
               return matches
       
           # Collect (profile_name, skills_dir) for every profile EXCEPT the
      -    # one whose SKILLS_DIR we already searched in _find_skill().
      -    active_dir = SKILLS_DIR.resolve() if SKILLS_DIR.exists() else SKILLS_DIR
      +    # one whose skills dir we already searched in _find_skill().
      +    _active = _skills_dir()
      +    active_dir = _active.resolve() if _active.exists() else _active
           candidates: List[Tuple[str, Path]] = []
       
           # Default profile (~/.hermes/skills) — only consider when active is non-default.
      @@ -828,7 +845,7 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
           result = {
               "success": True,
               "message": f"Skill '{name}' created.",
      -        "path": str(skill_dir.relative_to(SKILLS_DIR)),
      +        "path": str(skill_dir.relative_to(_skills_dir())),
               "skill_md": str(skill_md),
               "_change": {"description": _desc},
           }
    • tools/skills_tool.py
      @@ -92,6 +92,22 @@
       # skills all coexist here without polluting the git repo.
       HERMES_HOME = get_hermes_home()
       SKILLS_DIR = HERMES_HOME / "skills"
      +_SKILLS_DIR_AT_IMPORT = SKILLS_DIR
      +
      +
      +def _skills_dir() -> Path:
      +    """Return the active profile's skills directory at call time.
      +
      +    Some long-lived runtimes import this module before the active profile has
      +    set HERMES_HOME. Keep the legacy SKILLS_DIR module attribute for tests and
      +    external patchers, but when it has not been patched, resolve from the live
      +    profile-scoped HERMES_HOME on every call.
      +    """
      +    configured = Path(SKILLS_DIR)
      +    if configured != _SKILLS_DIR_AT_IMPORT:
      +        return configured
      +    return get_hermes_home() / "skills"
      +
       
       # Anthropic-recommended limits for progressive disclosure efficiency
       MAX_NAME_LENGTH = 64
      @@ -501,9 +517,9 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]:
           For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops"
           Also works for external skill dirs configured via skills.external_dirs.
           """
      -    # Try the module-level SKILLS_DIR first (respects monkeypatching in tests),
      +    # Try the active profile skills dir first (respects monkeypatching in tests),
           # then fall back to external dirs from config.
      -    dirs_to_check = [SKILLS_DIR]
      +    dirs_to_check = [_skills_dir()]
           try:
               from agent.skill_utils import get_external_skills_dirs
               dirs_to_check.extend(get_external_skills_dirs())
      @@ -622,8 +638,9 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]:
       
           # Scan local dir first, then external dirs (local takes precedence)
           dirs_to_scan = []
      -    if SKILLS_DIR.exists():
      -        dirs_to_scan.append(SKILLS_DIR)
      +    active_skills_dir = _skills_dir()
      +    if active_skills_dir.exists():
      +        dirs_to_scan.append(active_skills_dir)
           dirs_to_scan.extend(get_external_skills_dirs())
       
           for scan_dir in dirs_to_scan:
      @@ -701,8 +718,9 @@ def skills_list(category: str = None, task_id: str = None) -> str:
               JSON string with minimal skill info: name, description, category
           """
           try:
      -        if not SKILLS_DIR.exists():
      -            SKILLS_DIR.mkdir(parents=True, exist_ok=True)
      +        active_skills_dir = _skills_dir()
      +        if not active_skills_dir.exists():
      +            active_skills_dir.mkdir(parents=True, exist_ok=True)
                   return json.dumps(
                       {
                           "success": True,
      @@ -984,8 +1002,9 @@ def skill_view(
       
               # Build list of all skill directories to search
               all_dirs = []
      -        if SKILLS_DIR.exists():
      -            all_dirs.append(SKILLS_DIR)
      +        active_skills_dir = _skills_dir()
      +        if active_skills_dir.exists():
      +            all_dirs.append(active_skills_dir)
               all_dirs.extend(get_external_skills_dirs())
       
               if not all_dirs:
      @@ -1135,7 +1154,7 @@ def _record(sd: Optional[Path], smd: Path) -> None:
               # Security: warn if skill is loaded from outside trusted directories
               # (local skills dir + configured external_dirs are all trusted)
               _outside_skills_dir = True
      -        _trusted_dirs = [SKILLS_DIR.resolve()]
      +        _trusted_dirs = [active_skills_dir.resolve()]
               try:
                   _trusted_dirs.extend(d.resolve() for d in all_dirs[1:])
               except Exception:
      @@ -1375,7 +1394,7 @@ def _record(sd: Optional[Path], smd: Path) -> None:
                   linked_files["scripts"] = script_files
       
               try:
      -            rel_path = str(skill_md.relative_to(SKILLS_DIR))
      +            rel_path = str(skill_md.relative_to(active_skills_dir))
               except ValueError:
                   # External skill — use path relative to the skill's own parent dir
                   rel_path = str(skill_md.relative_to(skill_md.parent.parent)) if skill_md.parent.parent else skill_md.name
    • tui_gateway/server.py
      @@ -275,7 +275,7 @@ def close(self) -> None:
       class _SlashWorker:
           """Persistent HermesCLI subprocess for slash commands."""
       
      -    def __init__(self, session_key: str, model: str):
      +    def __init__(self, session_key: str, model: str, profile_home: str | None = None):
               self._lock = threading.Lock()
               self._seq = 0
               self.stderr_tail: list[str] = []
      @@ -294,6 +294,15 @@ def __init__(self, session_key: str, model: str):
               self._closed = False
               from hermes_cli._subprocess_compat import windows_hide_flags
       
      +        # slash_worker runs the Hermes agent → needs provider credentials.
      +        # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157).
      +        env = hermes_subprocess_env(inherit_credentials=True)
      +        if profile_home:
      +            # Global-remote / multi-profile sessions: the worker must resolve
      +            # config/skills/state against the session's profile home, not the
      +            # gateway's launch HERMES_HOME (#40677).
      +            env["HERMES_HOME"] = str(profile_home)
      +
               # start_new_session=True detaches the slash worker into its own
               # process group / session. Without this, the worker inherits the
               # gateway's pgid (= TUI parent PID). When mcp_tool's
      @@ -310,9 +319,7 @@ def __init__(self, session_key: str, model: str):
                   text=True,
                   bufsize=1,
                   cwd=os.getcwd(),
      -            # slash_worker runs the Hermes agent → needs provider credentials.
      -            # Tier-1 secrets (gateway/GitHub/infra) are still stripped (#29157).
      -            env=hermes_subprocess_env(inherit_credentials=[redacted:secret-assignment]
      +            env=env,
                   creationflags=windows_hide_flags(),
                   start_new_session=True,
               )
      @@ -1299,7 +1306,11 @@ def _build() -> None:
                   current["config_model_seen"] = _config_model_target()
       
                   try:
      -                worker = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
      +                worker = _SlashWorker(
      +                    key,
      +                    getattr(agent, "model", _resolve_model()),
      +                    profile_home=current.get("profile_home"),
      +                )
                       _attach_worker(sid, current, worker)
                   except Exception:
                       pass
      @@ -2628,6 +2639,7 @@ def _restart_slash_worker(sid: str, session: dict):
               new_worker = _SlashWorker(
                   session["session_key"],
                   getattr(session.get("agent"), "model", _resolve_model()),
      +            profile_home=session.get("profile_home"),
               )
           except Exception:
               session["slash_worker"] = None
      @@ -4514,7 +4526,11 @@ def _init_session(
               _attach_worker(
                   sid,
                   _sessions[sid],
      -            _SlashWorker(key, getattr(agent, "model", _resolve_model())),
      +            _SlashWorker(
      +                key,
      +                getattr(agent, "model", _resolve_model()),
      +                profile_home=_sessions[sid].get("profile_home"),
      +            ),
               )
           except Exception:
               # Defer hard-failure to slash.exec; chat still works without slash worker.
      @@ -12710,6 +12726,7 @@ def _(rid, params: dict) -> dict:
                   worker = _SlashWorker(
                       session["session_key"],
                       getattr(session.get("agent"), "model", _resolve_model()),
      +                profile_home=session.get("profile_home"),
                   )
                   _attach_worker(params.get("session_id", ""), session, worker)
               except Exception as e:
  • #73521 fix(desktop): voice profile scoping, remote media playback, stop-button state, read-aloud content

    merged to main

    files: apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts, apps/desktop/src/components/assistant-ui/markdown-text.media.test.tsx, apps/desktop/src/components/assistant-ui/markdown-text.tsx, apps/desktop/src/hermes-profile-scope.test.ts, apps/desktop/src/hermes.test.ts, apps/desktop/src/hermes.ts, apps/desktop/src/lib/media.remote.test.ts, apps/desktop/src/lib/media.ts, apps/desktop/src/lib/speech-text.test.ts, apps/desktop/src/lib/speech-text.ts, apps/desktop/src/lib/voice-playback.ts, contributors/emails/[redacted:email], contributors/emails/[redacted:email], contributors/emails/[redacted:email], hermes_cli/web_server.py, tests/hermes_cli/test_web_server_profile_unification.py

7 PRs closed without merging

From the close comments: done elsewhere 3 · merged via another change 2 · superseded by another PR 2

  • #40959 fix(tui): pass profile_home to slash_worker subprocess (#40677) · merged via another change → #60180, #36847, #56689, #40677
  • #41526 fix(tui_gateway): pass profile to slash worker subprocess · done elsewhere → 4a99571, #40677
  • #45522 fix(desktop): route TTS through active profile · superseded by another PR → #53470
  • #53470 fix(desktop): scope audio endpoints to the active profile · merged via another change → 097f0d0, #73521
  • #53472 fix(desktop): scope audio helpers to active profile · done elsewhere → 097f0d0, #73521, #53470, #69491
Show 2 more
  • #53488 fix: scope desktop audio requests to profile · done elsewhere → 097f0d0, #73521, #53470, #69491
  • #60257 fix(skills): invalidate slash-command cache on profile change in long-lived runtimes · superseded by another PR
Closed issues · 6
  • Closed#40677[Bug]: Profile-local skills are unavailable in Dashboard/TUI/Desktop GUI because child processes use the root HERMES_HOME
  • Closed#41517Desktop/Dashboard chat worker loses selected profile and falls back to default
  • Closed#45506Desktop voice mode uses default profile TTS config for non-default profiles
  • Closed#53441Desktop audio endpoints ignore active profile config
  • Closed#64057[Bug]: TTS voice selection ignores mismatched global vs profile config on desktop
  • Closed#66012[Bug]: Hermes Desktop ignores per-profile tts/voice config; always uses global/default TTS provider
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.11: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#60180, #73521
Closed issues
6
Attempts
7 PRs closed without merging
Fix link checked
28 Jul 2026

For your agent

The same facts, typed.