diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5ef8c..b971045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ --- +## [v0.24] Sprint 22 -- Multi-Profile Support (Issue #28) +*April 3, 2026 | 415 tests* + +### Features +- **Profile picker (topbar).** Purple-accented chip with SVG user icon. Click + to open dropdown listing all profiles with gateway status dots (green = + running), model info, and skill count. Click any profile to switch; "Manage + profiles" link opens the sidebar panel. +- **Profiles management panel.** New sidebar tab with full CRUD UI. Profile + cards show name, model/provider, skill count, API key status, and gateway + status badge. "Use" button switches profile, delete button removes non-default + profiles (with confirmation). +- **Profile creation.** "+ New profile" form with name validation (`[a-z0-9_-]`), + optional "clone config from active" checkbox. Wraps the CLI's + `hermes_cli.profiles.create_profile()`. +- **Profile deletion.** Confirm dialog. Auto-switches to default if deleting + the active profile. Blocked while agent is running. +- **Seamless profile switching.** No server restart. Profile switch updates + `HERMES_HOME`, patches module-level caches in hermes-agent's `skills_tool` + and `cron/jobs`, reloads `.env` API keys and `config.yaml`, refreshes the + model dropdown, skills, memory, and cron panels. +- **Per-session profile tracking.** `profile` field on Session records which + profile was active at creation. Backward-compatible (`null` for old sessions). + +### Bug Fixes +- **Hardcoded `~/.hermes` paths.** Memory read/write and model discovery used + hardcoded paths. Now resolved through `get_active_hermes_home()`. +- **Module-level path caching.** hermes-agent modules snapshot `HERMES_HOME` + at import time. Profile switch now monkey-patches `SKILLS_DIR`, `CRON_DIR`, + `JOBS_FILE`, `OUTPUT_DIR` so they track the active profile. + +### Architecture +- New `api/profiles.py`: profile state management wrapping `hermes_cli.profiles`. + Thread-safe (`_profile_lock`). Lazy imports avoid circular deps. +- `api/config.py`: module-level `cfg` replaced with reloadable `get_config()` + / `reload_config()`. Dynamic `_get_config_path()` resolves through profile. +- `api/streaming.py`: `HERMES_HOME` added to env save/restore block. +- Profile switch blocked while agent streams are active. +- 5 new API endpoints: `GET /api/profiles`, `GET /api/profile/active`, + `POST /api/profile/switch`, `POST /api/profile/create`, + `POST /api/profile/delete`. +- Zero modifications to hermes-agent code. + +--- + ## [v0.23] Sprint 21 -- Mobile Responsive + Docker *April 3, 2026 | 415 tests* @@ -748,4 +793,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.23, April 3, 2026 | Tests: 415* +*Last updated: v0.24, April 3, 2026 | Tests: 415* diff --git a/SPRINTS.md b/SPRINTS.md index 3913cf7..3f22231 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.23 | 415 tests | Daily driver ready +> Current state: v0.24 | 415 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -454,14 +454,60 @@ enables deployment beyond localhost. Both were achievable without new dependenci --- -## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28) +## Sprint 22 -- Multi-Profile Support (COMPLETED, Issue #28) -**Theme:** Switch between Hermes agent profiles seamlessly. +**Theme:** Switch between Hermes agent profiles seamlessly from the web UI. + +**Why now:** Issue #28 requested full profile management in the UI. The CLI has +had comprehensive profile support since v0.6.0 — isolated instances with their +own config, skills, memory, cron, and API keys. The web UI was locked to a +single default profile, blocking multi-persona workflows. + +### Track A: Bugs +- **Hardcoded `~/.hermes` paths.** Memory read/write in routes.py and model + discovery in config.py used hardcoded paths instead of the active profile's + directory. Fixed to resolve through `get_active_hermes_home()`. +- **Module-level cached paths.** hermes-agent's `skills_tool.py` and `cron/jobs.py` + snapshot `HERMES_HOME` at import time. Profile switch now monkey-patches these + cached variables (`SKILLS_DIR`, `CRON_DIR`, `JOBS_FILE`, `OUTPUT_DIR`). ### Track B: Features -- **Profile picker.** Sidebar or topbar dropdown to switch profiles. -- **Per-profile config.** Each profile has its own skills, memory, config.yaml. -- **Seamless switching.** No restart required. +- **Profile picker (topbar).** Purple-accented chip with SVG user icon in the + topbar. Click opens a dropdown listing all profiles with gateway status dots, + model info, and skill count. Click to switch; "Manage profiles" link opens + the management panel. +- **Profiles sidebar panel.** New nav tab with full management UI. Cards show + each profile with model, provider, skill count, API key status, and gateway + badge. "Use" button to switch, delete button for non-default profiles. +- **Profile creation.** "+ New profile" form with name validation (lowercase + alphanumeric + hyphens), optional "clone config from active" checkbox. Wraps + `hermes_cli.profiles.create_profile()`. +- **Profile deletion.** Confirm dialog, auto-switches to default if deleting + the active profile. Blocked while agent is running. +- **Seamless switching.** No server restart required. Profile switch updates + `HERMES_HOME` env var, patches module-level caches, reloads `.env` API keys, + reloads `config.yaml`, and refreshes the model dropdown, skills, memory, and + cron panels. +- **Per-session profile tracking.** New `profile` field on Session records which + profile was active when the session was created. Backward-compatible (defaults + to `null` for old sessions). + +### Track C: Architecture +- New `api/profiles.py` module (~200 lines): profile state management wrapping + `hermes_cli.profiles`. Thread-safe with `_profile_lock`. Lazy imports to + avoid circular dependencies. +- `api/config.py`: Replaced module-level `cfg` dict with reloadable + `get_config()`/`reload_config()`. Dynamic `_get_config_path()` resolves + through active profile. +- `api/streaming.py`: `HERMES_HOME` added to env save/restore block around + agent runs (alongside `TERMINAL_CWD`, `HERMES_EXEC_ASK`). +- Profile switch blocked while any agent stream is active (process-global + `HERMES_HOME` cannot be changed mid-run). +- Zero modifications to hermes-agent code required. + +**Tests:** 0 new (profile management requires hermes-agent integration). Total: 415. +**Hermes CLI parity impact:** Very High (profile support is a major CLI feature) +**Claude parity impact:** Low (Claude has no profile concept) --- @@ -510,7 +556,8 @@ enables deployment beyond localhost. Both were achievable without new dependenci | Slash commands | Done (Sprint 17) | | Thinking/reasoning display | Done (Sprint 18) | | Auth / login | Done (Sprint 19) | -| Voice input | Sprint 20 | +| Voice input | Done (Sprint 20) | +| Multi-profile support | Done (Sprint 22) | | Subagent visibility | Deferred | | Code execution (Jupyter) | Deferred | | Toolset control | Deferred | @@ -538,11 +585,11 @@ enables deployment beyond localhost. Both were achievable without new dependenci | Mobile layout (basic) | Done (v0.16.1) | | Workspace tree view | Done (Sprint 18) | | Slash commands | Done (Sprint 17) | -| Voice input | Sprint 20 | -| TTS playback | Sprint 20 | +| Voice input | Done (Sprint 20) | +| TTS playback | Deferred | | Artifacts (HTML/SVG preview) | Deferred | | Code execution inline | Deferred | -| Mobile-optimized layout | Sprint 21 | +| Mobile-optimized layout | Done (Sprint 21) | | Sharing / public URLs | Not planned (requires server infra) | | Claude-specific features | Not replicable (Projects AI, artifacts sync) | @@ -560,5 +607,5 @@ enables deployment beyond localhost. Both were achievable without new dependenci --- *Last updated: April 3, 2026* -*Current version: v0.23 | 415 tests* -*Next sprint: Sprint 22 (Multi-Profile Support)* +*Current version: v0.24 | 415 tests* +*Next sprint: Sprint 23 (Desktop Application)* diff --git a/api/config.py b/api/config.py index 0550c53..b961754 100644 --- a/api/config.py +++ b/api/config.py @@ -134,17 +134,44 @@ if _AGENT_DIR is not None: else: _HERMES_FOUND = False -# ── Config file (optional YAML) ────────────────────────────────────────────── -CONFIG_PATH = Path(os.getenv( - 'HERMES_CONFIG_PATH', - str(HOME / '.hermes' / 'config.yaml') -)).expanduser() +# ── Config file (reloadable -- supports profile switching) ────────────────── +_cfg_cache = {} +_cfg_lock = threading.Lock() -try: - import yaml as _yaml - cfg = _yaml.safe_load(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {} -except Exception: - cfg = {} +def _get_config_path() -> Path: + """Return config.yaml path for the active profile.""" + env_override = os.getenv('HERMES_CONFIG_PATH') + if env_override: + return Path(env_override).expanduser() + try: + from api.profiles import get_active_hermes_home + return get_active_hermes_home() / 'config.yaml' + except ImportError: + return HOME / '.hermes' / 'config.yaml' + +def get_config() -> dict: + """Return the cached config dict, loading from disk if needed.""" + if not _cfg_cache: + reload_config() + return _cfg_cache + +def reload_config(): + """Reload config.yaml from the active profile's directory.""" + with _cfg_lock: + _cfg_cache.clear() + config_path = _get_config_path() + try: + import yaml as _yaml + if config_path.exists(): + loaded = _yaml.safe_load(config_path.read_text()) + if isinstance(loaded, dict): + _cfg_cache.update(loaded) + except Exception: + pass + +# Initial load +reload_config() +cfg = _cfg_cache # alias for backward compat with existing references # ── Default workspace discovery ─────────────────────────────────────────────── def _discover_default_workspace() -> Path: @@ -183,7 +210,7 @@ def print_startup_config(): f' state dir : {STATE_DIR}', f' workspace : {DEFAULT_WORKSPACE}', f' host:port : {HOST}:{PORT}', - f' config file : {CONFIG_PATH} {"(found)" if CONFIG_PATH.exists() else "(not found, using defaults)"}', + f' config file : {_get_config_path()} {"(found)" if _get_config_path().exists() else "(not found, using defaults)"}', '', ] print('\n'.join(lines), flush=True) @@ -234,11 +261,12 @@ MIME_MAP = { } # ── Toolsets (from config.yaml or hardcoded default) ───────────────────────── -CLI_TOOLSETS = cfg.get('platform_toolsets', {}).get('cli', [ +_DEFAULT_TOOLSETS = [ 'browser', 'clarify', 'code_execution', 'cronjob', 'delegation', 'file', 'image_gen', 'memory', 'session_search', 'skills', 'terminal', 'todo', 'web', 'webhook', -]) +] +CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS) # ── Model / provider discovery ─────────────────────────────────────────────── @@ -396,7 +424,11 @@ def get_available_models() -> dict: # 3. Try to read auth store for active provider (if hermes is installed) if not active_provider: - auth_store_path = HOME / '.hermes' / 'auth.json' + try: + from api.profiles import get_active_hermes_home as _gah + auth_store_path = _gah() / 'auth.json' + except ImportError: + auth_store_path = HOME / '.hermes' / 'auth.json' if auth_store_path.exists(): try: import json as _j @@ -406,7 +438,11 @@ def get_available_models() -> dict: pass # 4. Check for API keys that imply available providers - hermes_env_path = HOME / '.hermes' / '.env' + try: + from api.profiles import get_active_hermes_home as _gah2 + hermes_env_path = _gah2() / '.env' + except ImportError: + hermes_env_path = HOME / '.hermes' / '.env' env_keys = {} if hermes_env_path.exists(): try: @@ -655,3 +691,11 @@ if SETTINGS_FILE.exists(): # ── SESSIONS in-memory cache (LRU OrderedDict) ─────────────────────────────── SESSIONS: collections.OrderedDict = collections.OrderedDict() + +# ── Profile state initialisation ──────────────────────────────────────────── +# Must run after all imports are resolved to correctly patch module-level caches +try: + from api.profiles import init_profile_state + init_profile_state() +except ImportError: + pass # hermes_cli not available -- default profile only diff --git a/api/models.py b/api/models.py index e3f8b05..ad9d807 100644 --- a/api/models.py +++ b/api/models.py @@ -34,8 +34,8 @@ def _write_session_index(): class Session: - def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, **kwargs): - self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None + def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs): + self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile @property def path(self): return SESSION_DIR / f'{self.session_id}.json' def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index() @@ -44,7 +44,7 @@ class Session: p = SESSION_DIR / f'{sid}.json' if not p.exists(): return None return cls(**json.loads(p.read_text(encoding='utf-8'))) - def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id} + def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile} def get_session(sid): with LOCK: @@ -63,7 +63,12 @@ def get_session(sid): def new_session(workspace=None, model=None): # Use _cfg.DEFAULT_MODEL (not the import-time snapshot) so save_settings() changes take effect - s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL) + try: + from api.profiles import get_active_profile_name + _profile = get_active_profile_name() + except ImportError: + _profile = None + s = Session(workspace=workspace or get_last_workspace(), model=model or _cfg.DEFAULT_MODEL, profile=_profile) with LOCK: SESSIONS[s.session_id] = s SESSIONS.move_to_end(s.session_id) diff --git a/api/profiles.py b/api/profiles.py new file mode 100644 index 0000000..47559ff --- /dev/null +++ b/api/profiles.py @@ -0,0 +1,246 @@ +""" +Hermes Web UI -- Profile state management. +Wraps hermes_cli.profiles to provide profile switching for the web UI. + +The web UI maintains a process-level "active profile" that determines which +HERMES_HOME directory is used for config, skills, memory, cron, and API keys. +Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level +cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot +HERMES_HOME at import time. +""" +import json +import os +import threading +from pathlib import Path + +# ── Module state ──────────────────────────────────────────────────────────── +_active_profile = 'default' +_profile_lock = threading.Lock() +_DEFAULT_HERMES_HOME = Path.home() / '.hermes' + + +def _read_active_profile_file() -> str: + """Read the sticky active profile from ~/.hermes/active_profile.""" + ap_file = _DEFAULT_HERMES_HOME / 'active_profile' + if ap_file.exists(): + try: + name = ap_file.read_text().strip() + if name: + return name + except Exception: + pass + return 'default' + + +# ── Public API ────────────────────────────────────────────────────────────── + +def get_active_profile_name() -> str: + """Return the currently active profile name.""" + return _active_profile + + +def get_active_hermes_home() -> Path: + """Return the HERMES_HOME path for the currently active profile.""" + if _active_profile == 'default': + return _DEFAULT_HERMES_HOME + profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile + if profile_dir.is_dir(): + return profile_dir + return _DEFAULT_HERMES_HOME + + +def _set_hermes_home(home: Path): + """Set HERMES_HOME env var and monkey-patch cached module-level paths.""" + os.environ['HERMES_HOME'] = str(home) + + # Patch skills_tool module-level cache (snapshots HERMES_HOME at import) + try: + import tools.skills_tool as _sk + _sk.HERMES_HOME = home + _sk.SKILLS_DIR = home / 'skills' + except (ImportError, AttributeError): + pass + + # Patch cron/jobs module-level cache + try: + import cron.jobs as _cj + _cj.HERMES_DIR = home + _cj.CRON_DIR = home / 'cron' + _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json' + _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' + except (ImportError, AttributeError): + pass + + +def _reload_dotenv(home: Path): + """Load .env from the profile dir into os.environ (additive).""" + env_path = home / '.env' + if not env_path.exists(): + return + try: + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith('#') and '=' in line: + k, v = line.split('=', 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and v: + os.environ[k] = v + except Exception: + pass + + +def init_profile_state(): + """Initialize profile state at server startup. + + Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches + module-level cached paths. Called once from config.py after imports. + """ + global _active_profile + _active_profile = _read_active_profile_file() + home = get_active_hermes_home() + _set_hermes_home(home) + _reload_dotenv(home) + + +def switch_profile(name: str) -> dict: + """Switch the active profile. + + Validates the profile exists, updates process state, patches module caches, + reloads .env, and reloads config.yaml. + + Returns: {'profiles': [...], 'active': name} + Raises ValueError if profile doesn't exist or agent is busy. + """ + global _active_profile + + # Import here to avoid circular import at module load + from api.config import STREAMS, STREAMS_LOCK, reload_config + + # Block if agent is running + with STREAMS_LOCK: + if len(STREAMS) > 0: + raise RuntimeError( + 'Cannot switch profiles while an agent is running. ' + 'Cancel or wait for it to finish.' + ) + + # Resolve profile directory + if name == 'default': + home = _DEFAULT_HERMES_HOME + else: + home = _DEFAULT_HERMES_HOME / 'profiles' / name + if not home.is_dir(): + raise ValueError(f"Profile '{name}' does not exist.") + + with _profile_lock: + _active_profile = name + _set_hermes_home(home) + _reload_dotenv(home) + + # Write sticky default for CLI consistency + try: + ap_file = _DEFAULT_HERMES_HOME / 'active_profile' + ap_file.write_text(name if name != 'default' else '') + except Exception: + pass + + # Reload config.yaml from the new profile + reload_config() + + return {'profiles': list_profiles_api(), 'active': name} + + +def list_profiles_api() -> list: + """List all profiles with metadata, serialized for JSON response.""" + try: + from hermes_cli.profiles import list_profiles + infos = list_profiles() + except ImportError: + # hermes_cli not available -- return just the default + return [_default_profile_dict()] + + active = _active_profile + result = [] + for p in infos: + result.append({ + 'name': p.name, + 'path': str(p.path), + 'is_default': p.is_default, + 'is_active': p.name == active, + 'gateway_running': p.gateway_running, + 'model': p.model, + 'provider': p.provider, + 'has_env': p.has_env, + 'skill_count': p.skill_count, + }) + return result + + +def _default_profile_dict() -> dict: + """Fallback profile dict when hermes_cli is not importable.""" + return { + 'name': 'default', + 'path': str(_DEFAULT_HERMES_HOME), + 'is_default': True, + 'is_active': True, + 'gateway_running': False, + 'model': None, + 'provider': None, + 'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(), + 'skill_count': 0, + } + + +def create_profile_api(name: str, clone_from: str = None, + clone_config: bool = False) -> dict: + """Create a new profile. Returns the new profile info dict.""" + try: + from hermes_cli.profiles import create_profile, validate_profile_name + except ImportError: + raise RuntimeError('Profile management requires hermes-agent to be installed.') + + validate_profile_name(name) + create_profile( + name, + clone_from=clone_from, + clone_config=clone_config, + clone_all=False, + no_alias=True, + ) + + # Find and return the newly created profile info + for p in list_profiles_api(): + if p['name'] == name: + return p + return {'name': name, 'path': str(_DEFAULT_HERMES_HOME / 'profiles' / name)} + + +def delete_profile_api(name: str) -> dict: + """Delete a profile. Switches to default first if it's the active one.""" + if name == 'default': + raise ValueError("Cannot delete the default profile.") + + # If deleting the active profile, switch to default first + if _active_profile == name: + try: + switch_profile('default') + except RuntimeError: + raise RuntimeError( + f"Cannot delete active profile '{name}' while an agent is running. " + "Cancel or wait for it to finish." + ) + + try: + from hermes_cli.profiles import delete_profile + delete_profile(name, yes=True) + except ImportError: + # Manual fallback: just remove the directory + import shutil + profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name + if profile_dir.is_dir(): + shutil.rmtree(str(profile_dir)) + else: + raise ValueError(f"Profile '{name}' does not exist.") + + return {'ok': True, 'name': name} diff --git a/api/routes.py b/api/routes.py index 51a0c01..f0bafc5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -234,6 +234,15 @@ def handle_get(handler, parsed): if parsed.path == '/api/memory': return _handle_memory_read(handler) + # ── Profile API (GET) ── + if parsed.path == '/api/profiles': + from api.profiles import list_profiles_api, get_active_profile_name + return j(handler, {'profiles': list_profiles_api(), 'active': get_active_profile_name()}) + + if parsed.path == '/api/profile/active': + from api.profiles import get_active_profile_name, get_active_hermes_home + return j(handler, {'name': get_active_profile_name(), 'path': str(get_active_hermes_home())}) + return False # 404 @@ -372,6 +381,53 @@ def handle_post(handler, parsed): if parsed.path == '/api/memory/write': return _handle_memory_write(handler, body) + # ── Profile API (POST) ── + if parsed.path == '/api/profile/switch': + name = body.get('name', '').strip() + if not name: return bad(handler, 'name is required') + try: + from api.profiles import switch_profile + result = switch_profile(name) + return j(handler, result) + except (ValueError, FileNotFoundError) as e: + return bad(handler, str(e), 404) + except RuntimeError as e: + return bad(handler, str(e), 409) + + if parsed.path == '/api/profile/create': + name = body.get('name', '').strip() + if not name: return bad(handler, 'name is required') + import re as _re + if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', name): + return bad(handler, 'Invalid profile name: lowercase letters, numbers, hyphens, underscores only') + clone_from = body.get('clone_from') + if clone_from is not None: + clone_from = str(clone_from).strip() + if not _re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', clone_from): + return bad(handler, 'Invalid clone_from name') + try: + from api.profiles import create_profile_api + result = create_profile_api( + name, + clone_from=clone_from, + clone_config=bool(body.get('clone_config', False)), + ) + return j(handler, {'ok': True, 'profile': result}) + except (ValueError, FileExistsError, RuntimeError) as e: + return bad(handler, str(e)) + + if parsed.path == '/api/profile/delete': + name = body.get('name', '').strip() + if not name: return bad(handler, 'name is required') + try: + from api.profiles import delete_profile_api + result = delete_profile_api(name) + return j(handler, result) + except (ValueError, FileNotFoundError) as e: + return bad(handler, str(e)) + except RuntimeError as e: + return bad(handler, str(e), 409) + # ── Settings (POST) ── if parsed.path == '/api/settings': saved = save_settings(body) @@ -731,7 +787,11 @@ def _handle_cron_recent(handler, parsed): def _handle_memory_read(handler): - mem_dir = Path.home() / '.hermes' / 'memories' + try: + from api.profiles import get_active_hermes_home + mem_dir = get_active_hermes_home() / 'memories' + except ImportError: + mem_dir = Path.home() / '.hermes' / 'memories' mem_file = mem_dir / 'MEMORY.md' user_file = mem_dir / 'USER.md' memory = mem_file.read_text(encoding='utf-8', errors='replace') if mem_file.exists() else '' @@ -1078,7 +1138,11 @@ def _handle_skill_delete(handler, body): def _handle_memory_write(handler, body): try: require(body, 'section', 'content') except ValueError as e: return bad(handler, str(e)) - mem_dir = Path.home() / '.hermes' / 'memories' + try: + from api.profiles import get_active_hermes_home + mem_dir = get_active_hermes_home() / 'memories' + except ImportError: + mem_dir = Path.home() / '.hermes' / 'memories' mem_dir.mkdir(parents=True, exist_ok=True) section = body['section'] if section == 'memory': diff --git a/api/streaming.py b/api/streaming.py index 385a59a..99bc531 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -64,19 +64,30 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta put('cancel', {'message': 'Cancelled before start'}) return + # Resolve profile home for this agent run (snapshot at start) + try: + from api.profiles import get_active_hermes_home + _profile_home = str(get_active_hermes_home()) + except ImportError: + _profile_home = os.environ.get('HERMES_HOME', '') + _set_thread_env( TERMINAL_CWD=str(s.workspace), HERMES_EXEC_ASK='1', HERMES_SESSION_KEY=session_id, + HERMES_HOME=_profile_home, ) # Still set process-level env as fallback for tools that bypass thread-local with _agent_lock: old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY') + old_hermes_home = os.environ.get('HERMES_HOME') os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id + if _profile_home: + os.environ['HERMES_HOME'] = _profile_home try: def on_token(text): @@ -187,6 +198,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None) else: os.environ['HERMES_SESSION_KEY'] = old_session_key + if old_hermes_home is None: os.environ.pop('HERMES_HOME', None) + else: os.environ['HERMES_HOME'] = old_hermes_home except Exception as e: print('[webui] stream error:\n' + traceback.format_exc(), flush=True) diff --git a/static/boot.js b/static/boot.js index bd1d3f8..cff23c6 100644 --- a/static/boot.js +++ b/static/boot.js @@ -309,6 +309,11 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ (async()=>{ // Load send key preference try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';} + // Fetch active profile + try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} + // Update profile chip label immediately + const profileLabel=$('profileChipLabel'); + if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; // Fetch available models from server and populate dropdown dynamically await populateModelDropdown(); // Restore last-used model preference diff --git a/static/index.html b/static/index.html index 4126a87..7c350c1 100644 --- a/static/index.html +++ b/static/index.html @@ -13,13 +13,14 @@
+ +
+ + + +
Loading...
+