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
- 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
Open PRs0
Fixes on record
-
#60180 fix(skills): profile-aware skills resolution in long-lived runtimes (#40677)
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.pyShow 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
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
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.00means 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