Under gateway.multiplex_profiles a secondary profile's turn ran with the LAUNCH profile's working directory, command allowlist, redact_secrets switch, credential file mounts, browser engine/headed flags, LSP service, auxiliary-provider health marks and MCP stderr log, and several TERMINAL_ENV consumers read the process env instead of the routed profile's terminal scope. A standalone `hermes -p X gateway run` never behaved that way. - tools/terminal_scope.py: resolve the terminal.cwd placeholder inside the profile scope with the same rule gateway/run.py applies at import (local -> $HOME, sandbox default otherwise) so the system prompt, context files and the terminal of a routed turn start where the profile's standalone gateway would. - tools/image_source.py, credential_files.py, image_generation_tool.py, skills_tool.py, delegate_tool_progress.py, agent/tool_executor.py: read TERMINAL_ENV / TERMINAL_CWD through the terminal scope. - tools/approval.py (+ approval_floors.py): one permanent allowlist per routed profile home; the unscoped module set stays for single-profile processes. - agent/redact.py: `_redact_enabled()` resolves security.redact_secrets for the routed profile (scope .env, then config); launch snapshot kept when unscoped. - tools/credential_files.py, agent/auxiliary_health.py, agent/lsp/__init__.py, tools/browser_tool_cloud.py, tools/mcp_tool_config.py, tools/tool_result_storage.py: key process caches by profile home (or bypass the slot under an override). Tests: tests/tools/test_multiplex_turn_parity.py (4, red on base). Docs: multi-profile-gateways.md isolation table.
90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
"""Language Server Protocol (LSP) integration for Hermes Agent.
|
|
|
|
Real language servers (pyright, gopls, ...) run as subprocesses and their
|
|
``publishDiagnostics`` feed the post-write lint delta filter of ``write_file`` /
|
|
``patch`` (wiring: ``FileOperations._check_lint_delta``). LSP is **gated on git
|
|
workspace detection** so user-home cwd's (e.g. Telegram gateway chats) never
|
|
spawn daemons; ``get_service()`` returns the singleton or ``None`` when disabled.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import logging
|
|
import threading
|
|
from typing import Optional
|
|
|
|
from agent.lsp.manager import LSPService
|
|
|
|
logger = logging.getLogger("agent.lsp")
|
|
|
|
_service: Optional[LSPService] = None
|
|
# Routed multiplex profiles (HERMES_HOME override) each get their own service: ``lsp.*`` config
|
|
# (enabled, servers, idle timeout) is per profile, so one process-wide singleton would let the first
|
|
# profile's settings decide whether every other profile gets diagnostics.
|
|
_services_by_home: dict = {}
|
|
_atexit_registered = False
|
|
_service_lock = threading.Lock()
|
|
|
|
|
|
def _active(svc: Optional[LSPService]) -> Optional[LSPService]:
|
|
return svc if (svc is not None and svc.is_active()) else None
|
|
|
|
|
|
def _register_atexit_once() -> None:
|
|
global _atexit_registered
|
|
if not _atexit_registered:
|
|
atexit.register(_atexit_shutdown)
|
|
_atexit_registered = True
|
|
|
|
|
|
def get_service() -> Optional[LSPService]:
|
|
"""Return the lazily created LSP service for the active profile (process-wide singleton when no
|
|
profile override is bound), or None when disabled.
|
|
|
|
Also registers an :mod:`atexit` hook so a clean exit tears down spawned servers:
|
|
without it every ``hermes chat`` exit leaks pyright processes for a few seconds
|
|
while their stdout buffers drain. (SIGKILL/os._exit skip atexit — fine, the
|
|
kernel reaps the stateless servers with their parent.)
|
|
"""
|
|
global _service
|
|
from hermes_constants import get_hermes_home_override, hermes_home_key
|
|
if get_hermes_home_override() is not None:
|
|
home_key = hermes_home_key()
|
|
with _service_lock:
|
|
if home_key not in _services_by_home:
|
|
_services_by_home[home_key] = LSPService.create_from_config()
|
|
_register_atexit_once()
|
|
return _active(_services_by_home[home_key])
|
|
if _service is None:
|
|
with _service_lock:
|
|
if _service is None:
|
|
_service = LSPService.create_from_config()
|
|
_register_atexit_once()
|
|
return _active(_service)
|
|
|
|
|
|
def shutdown_service() -> None:
|
|
"""Tear down every LSP service that was started. Idempotent."""
|
|
global _service
|
|
with _service_lock:
|
|
services = [_service, *_services_by_home.values()]
|
|
_service = None
|
|
_services_by_home.clear()
|
|
for svc in services:
|
|
if svc is not None:
|
|
try:
|
|
svc.shutdown()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("LSP shutdown error: %s", e)
|
|
|
|
|
|
def _atexit_shutdown() -> None:
|
|
"""atexit wrapper; logs at debug since the user has already seen the final output."""
|
|
try:
|
|
shutdown_service()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.debug("atexit LSP shutdown failed: %s", e)
|
|
|
|
|
|
__all__ = ["get_service", "shutdown_service", "LSPService"]
|