Compare commits

...

2 Commits

Author SHA1 Message Date
ethernet
0b2dd9f6c1 savlage me
cant work because dashboard is what spawns gateway per profile :)
2026-06-11 19:32:28 -04:00
ethernet
13022f3e2a wip 2026-06-11 17:16:33 -04:00
11 changed files with 1924 additions and 56 deletions

View File

@@ -249,6 +249,32 @@ function resolveHermesHome() {
}
const HERMES_HOME = resolveHermesHome()
// Read a profile's gateway_http.json file and return its contents as an object,
// or null if the file doesn't exist, is stale (PID not alive), or is corrupted.
// This is the JS mirror of gateway/status.py::read_gateway_http_info.
function readGatewayHttpInfo(profile) {
try {
const profileHome = (!profile || profile === 'default')
? HERMES_HOME
: path.join(HERMES_HOME, 'profiles', profile)
const infoPath = path.join(profileHome, 'gateway_http.json')
if (!fileExists(infoPath)) return null
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8'))
if (!data || !data.port || !data.token || !data.base_url) return null
// Stale check: is the PID still alive?
if (data.pid) {
try {
process.kill(data.pid, 0) // throws if not alive
} catch {
return null // stale — gateway crashed without cleanup
}
}
return data
} catch {
return null
}
}
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end
// up with identical layouts and can share one install.
@@ -1792,14 +1818,15 @@ async function applyUpdatesPosixInApp() {
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
}
// `hermes update` reaps stale `hermes dashboard` backends (a code update
// leaves the running process serving old Python against the freshly-updated
// JS bundle). But OUR backend is one of those processes, and killing it
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
// already restarts its own backend via the rebuild+relaunch below, so the
// reap must spare it. Hand the live backend's PID to the update process;
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
// it while still reaping any genuinely-orphaned dashboards. (#37532)
// `hermes update` reaps stale `hermes dashboard` and `hermes gateway run`
// backends (a code update leaves the running process serving old Python
// against the freshly-updated JS bundle). But OUR backend is one of those
// processes, and killing it mid-update produces the boot→kill→crash loop
// in #37532 — the desktop already restarts its own backend via the
// rebuild+relaunch below, so the reap must spare it. Hand the live
// backend's PID to the update process; _kill_stale_dashboard_processes
// reads HERMES_DESKTOP_CHILD_PID and excludes it while still reaping
// any genuinely-orphaned backends. (#37532)
// Exclude every desktop-managed backend (primary + all pool profiles) from
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
// list (a single int still parses for back-compat).
@@ -4454,6 +4481,30 @@ async function ensureBackend(profile) {
return existing.connectionPromise
}
// Before spawning a new gateway process, check if one is already running
// (e.g. the user runs `hermes -p worker gateway run` themselves, or the
// desktop left a gateway running from a previous session that survived a
// renderer restart).
const alreadyRunning = readGatewayHttpInfo(key)
if (alreadyRunning) {
rememberLog(`Profile "${key}" gateway already running on port ${alreadyRunning.port} — reusing`)
const conn = {
baseUrl: alreadyRunning.base_url,
mode: 'local',
source: 'local',
authMode: 'token',
token: alreadyRunning.token,
profile: key,
wsUrl: `${alreadyRunning.ws_url}?token=${encodeURIComponent(alreadyRunning.token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
const entry = { process: null, port: alreadyRunning.port, token: alreadyRunning.token, connectionPromise: Promise.resolve(conn), lastActiveAt: Date.now() }
backendPool.set(key, entry)
startPoolIdleReaper()
return conn
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() }
@@ -4538,10 +4589,9 @@ async function spawnPoolBackend(profile, entry) {
const token = crypto.randomBytes(32).toString('base64url')
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const backendArgs = ['--profile', profile, 'gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
@@ -4555,11 +4605,11 @@ async function spawnPoolBackend(profile, entry) {
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
GATEWAY_HTTP_TOKEN: token,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
// Marks this gateway backend as desktop-spawned so it runs the cron
// scheduler tick loop.
HERMES_DESKTOP: '1'
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
@@ -4724,23 +4774,43 @@ async function startHermes() {
}
}
// Check if the primary profile's gateway is already running (e.g. started
// by the user from the CLI before launching the desktop). Reuse it rather
// than spawning a second gateway on a different port.
const activeProfile = readActiveDesktopProfile()
const primaryAlreadyRunning = readGatewayHttpInfo(activeProfile || null)
if (primaryAlreadyRunning) {
rememberLog(`Primary gateway already running on port ${primaryAlreadyRunning.port} — reusing`)
await advanceBootProgress('backend.wait', 'Connecting to existing Hermes gateway', 90)
await waitForHermes(primaryAlreadyRunning.base_url, primaryAlreadyRunning.token)
updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready', progress: 94, running: true, error: null })
return {
baseUrl: primaryAlreadyRunning.base_url,
mode: 'local',
source: 'local',
authMode: 'token',
token: primaryAlreadyRunning.token,
wsUrl: `${primaryAlreadyRunning.ws_url}?token=${encodeURIComponent(primaryAlreadyRunning.token)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
}
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
const port = await pickPort()
const token = crypto.randomBytes(32).toString('base64url')
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
const backendArgs = ['gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
// Pin the desktop's chosen profile via the global --profile flag. This is
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
// unset preference keeps the legacy launch so existing installs are
// unaffected.
const activeProfile = readActiveDesktopProfile()
if (activeProfile) {
dashboardArgs.unshift('--profile', activeProfile)
backendArgs.unshift('--profile', activeProfile)
}
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
const hermesCwd = resolveHermesCwd()
const webDist = resolveWebDist()
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
@@ -4753,18 +4823,18 @@ async function startHermes() {
// resolves to the SAME location our resolveHermesHome() picked. Without
// this pin, Python falls back to ~/.hermes on every platform — fine on
// mac/linux (where our default matches), but on Windows our default is
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
// %LOCALAPPDATA%\\hermes, which differs from C:\\Users\\<u>\\.hermes.
// Mismatch would split config / sessions / .env / logs across two
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
// can't reliably do that, so we set it inline for every spawn.
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
GATEWAY_HTTP_TOKEN: token,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
// Marks this gateway backend as desktop-spawned so it runs the cron
// scheduler tick loop.
HERMES_DESKTOP: '1'
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']

View File

@@ -539,6 +539,12 @@ class GatewayConfig:
# Streaming configuration
streaming: StreamingConfig = field(default_factory=StreamingConfig)
# HTTP Management API configuration
http_enabled: bool = True
http_host: str = "127.0.0.1"
http_port: int = 0 # 0 = auto-assign
http_token: Optional[str] = None # None = auto-generate
# Session store pruning: drop SessionEntry records older than this many
# days from the in-memory dict and sessions.json. Keeps the store from
# growing unbounded in gateways serving many chats/threads/users over
@@ -641,6 +647,11 @@ class GatewayConfig:
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
# HTTP Management API
"http_enabled": self.http_enabled,
"http_host": self.http_host,
"http_port": self.http_port,
"http_token": self.http_token,
}
@classmethod
@@ -724,6 +735,11 @@ class GatewayConfig:
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
# HTTP Management API
http_enabled=_coerce_bool(data.get("http_enabled"), True),
http_host=data.get("http_host", "127.0.0.1"),
http_port=_coerce_optional_positive_int(data.get("http_port"), "http_port") or 0,
http_token=data.get("http_token"),
)
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
@@ -843,6 +859,23 @@ def load_gateway_config() -> GatewayConfig:
"pair",
)
# HTTP Management API config
http_section = None
if "gateway" in yaml_cfg and isinstance(yaml_cfg["gateway"], dict):
http_section = yaml_cfg["gateway"].get("http")
elif "http" in yaml_cfg:
http_section = yaml_cfg.get("http")
if isinstance(http_section, dict):
if "enabled" in http_section:
gw_data["http_enabled"] = http_section["enabled"]
if "host" in http_section:
gw_data["http_host"] = http_section["host"]
if "port" in http_section:
gw_data["http_port"] = http_section["port"]
if "token" in http_section:
gw_data["http_token"] = http_section["token"]
# Merge platform config into gw_data so runtime-only settings under
# ``gateway.platforms`` are loaded the same way as top-level
# ``platforms``. Merge nested first so top-level config keeps
@@ -2078,3 +2111,23 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
for platform_config in config.platforms.values():
platform_config.extra.pop("_enabled_explicit", None)
# HTTP Management API
http_enabled = os.getenv("GATEWAY_HTTP_ENABLED")
if http_enabled is not None:
config.http_enabled = http_enabled.lower() in {"true", "1", "yes"}
http_host = os.getenv("GATEWAY_HTTP_HOST")
if http_host:
config.http_host = http_host
http_port = os.getenv("GATEWAY_HTTP_PORT")
if http_port:
try:
config.http_port = int(http_port)
except ValueError:
pass
http_token = os.getenv("GATEWAY_HTTP_TOKEN") or os.getenv("HERMES_DASHBOARD_SESSION_TOKEN")
if http_token:
config.http_token = http_token

1328
gateway/http_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -15620,7 +15620,15 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
logger.info("Cron ticker stopped")
async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool:
async def start_gateway(
config: Optional[GatewayConfig] = None,
replace: bool = False,
verbosity: Optional[int] = 0,
http_port: Optional[int] = None,
http_host: Optional[str] = None,
http_token: Optional[str] = None,
http_enabled: Optional[bool] = None,
) -> bool:
"""
Start the gateway and run until interrupted.
@@ -15633,6 +15641,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
replace: If True, kill any existing gateway instance before starting.
Useful for systemd services to avoid restart-loop deadlocks
when the previous process hasn't fully exited yet.
http_port: HTTP management API port (0 = auto-assign, None = use config)
http_host: HTTP management API bind host (None = use config)
http_token: HTTP management API token (None = auto-generate)
http_enabled: If False, disable HTTP management API (None = use config)
"""
# ── Duplicate-instance guard ──────────────────────────────────────
# Prevent two gateways from running under the same HERMES_HOME.
@@ -15800,6 +15812,20 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
if _stderr_level < logging.getLogger().level:
logging.getLogger().setLevel(_stderr_level)
# Ensure config exists and apply HTTP management API CLI overrides
if config is None:
config = load_gateway_config()
# Apply HTTP management API CLI overrides
if http_enabled is not None:
config.http_enabled = http_enabled
if http_port is not None:
config.http_port = http_port
if http_host is not None:
config.http_host = http_host
if http_token is not None:
config.http_token = http_token
runner = GatewayRunner(config)
# Track whether an unexpected signal initiated the shutdown. When an
@@ -16013,6 +16039,35 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
return True
# Start HTTP Management API server
http_server = None
http_task = None
if config.http_enabled:
try:
import secrets
from gateway.http_api import run_http_server as _run_http_server
from gateway.status import write_gateway_http_info, remove_gateway_http_info
# Generate token if not provided
http_token = config.http_token or secrets.token_urlsafe(32)
config.http_token = http_token
http_server, actual_port = await _run_http_server(
runner=runner,
host=config.http_host,
port=config.http_port,
token=http_token,
)
config.http_port = actual_port
logger.info("HTTP Management API started on %s:%d", config.http_host, actual_port)
# Publish host/port/token so dashboard, desktop, and other tools
# can discover this gateway without needing to spawn it themselves.
write_gateway_http_info(config.http_host, actual_port, http_token)
atexit.register(remove_gateway_http_info)
except Exception as e:
logger.error("Failed to start HTTP Management API: %s", e)
# Start background cron ticker so scheduled jobs fire automatically.
# Pass the event loop so cron delivery can use live adapters (E2EE support).
cron_stop = threading.Event()
@@ -16036,7 +16091,17 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
# Stop cron ticker cleanly
cron_stop.set()
cron_thread.join(timeout=5)
# Stop HTTP Management API server
if http_server is not None:
try:
from gateway.status import remove_gateway_http_info
remove_gateway_http_info()
await http_server.shutdown()
logger.info("HTTP Management API stopped")
except Exception as e:
logger.debug("HTTP server shutdown error: %s", e)
# Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).
_planned_stop_watcher_stop.set()
_planned_stop_watcher_thread.join(timeout=2)

View File

@@ -555,6 +555,88 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
return _read_json_file(_get_runtime_status_path())
# ---------------------------------------------------------------------------
# HTTP management API discovery
# ---------------------------------------------------------------------------
#
# While the gateway is running it writes its HTTP management API address
# (host, port, token) to ``{HERMES_HOME}/gateway_http.json``. Any caller
# (dashboard, desktop, CLI tool) that wants to proxy a request to a specific
# profile's gateway reads this file to find where to connect.
#
# The file is profile-scoped because HERMES_HOME is profile-scoped: the
# "default" profile writes to ``~/.hermes/gateway_http.json`` and a named
# profile "worker" writes to ``~/.hermes/profiles/worker/gateway_http.json``.
#
# The PID in the file is checked for liveness so stale files from crashed
# gateways are treated as "not running".
# ---------------------------------------------------------------------------
_GATEWAY_HTTP_FILE = "gateway_http.json"
def _get_gateway_http_path(hermes_home: Optional[Path] = None) -> Path:
home = hermes_home if hermes_home is not None else get_hermes_home()
return home / _GATEWAY_HTTP_FILE
def write_gateway_http_info(
host: str,
port: int,
token: str,
hermes_home: Optional[Path] = None,
) -> None:
"""Persist this gateway's HTTP management API info so other processes can find it."""
path = _get_gateway_http_path(hermes_home)
_write_json_file(path, {
"host": host,
"port": port,
"token": token,
"pid": os.getpid(),
"base_url": f"http://{host}:{port}",
"ws_url": f"ws://{host}:{port}/api/ws",
})
def remove_gateway_http_info(hermes_home: Optional[Path] = None) -> None:
"""Remove this gateway's HTTP management API info on shutdown (best-effort)."""
path = _get_gateway_http_path(hermes_home)
if not path.exists():
return
# Only remove if it belongs to this process, to avoid clobbering a
# replacement gateway that already wrote its own file.
try:
data = _read_json_file(path)
if data and data.get("pid") == os.getpid():
path.unlink(missing_ok=True)
except Exception:
pass
def read_gateway_http_info(hermes_home: Optional[Path] = None) -> Optional[dict[str, Any]]:
"""Read and validate a gateway's HTTP management API info.
Returns ``None`` when no gateway is running (file absent, stale PID, or
corrupt JSON). Callers should fall back to direct file access when this
returns ``None``.
"""
path = _get_gateway_http_path(hermes_home)
data = _read_json_file(path)
if not data:
return None
pid = data.get("pid")
if pid and not _pid_exists(int(pid)):
# Stale file from a crashed gateway — clean up silently.
try:
path.unlink(missing_ok=True)
except Exception:
pass
return None
if not data.get("port") or not data.get("token"):
return None
return data
def remove_pid_file() -> None:
"""Remove the gateway PID file, but only if it belongs to this process.

View File

@@ -14,6 +14,7 @@ import sys
import textwrap
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@@ -3789,7 +3790,7 @@ def _guard_official_docker_root_gateway() -> None:
sys.exit(1)
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, http_port: Optional[int] = None, http_host: Optional[str] = None, http_token: Optional[str] = None, no_http: bool = False):
"""Run the gateway in foreground.
Args:
@@ -3798,6 +3799,10 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
replace: If True, kill any existing gateway instance before starting.
This prevents systemd restart loops when the old process
hasn't fully exited yet.
http_port: HTTP management API port (0 = auto-assign, default: from config)
http_host: HTTP management API bind host (default: from config)
http_token: HTTP management API token (auto-generated if not set)
no_http: If True, disable HTTP management API
"""
_guard_official_docker_root_gateway()
sys.path.insert(0, str(PROJECT_ROOT))
@@ -3923,7 +3928,14 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
success = False
try:
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
success = asyncio.run(start_gateway(
replace=replace,
verbosity=verbosity,
http_port=http_port,
http_host=http_host,
http_token=http_token,
http_enabled=not no_http,
))
_exit_diag("asyncio.run.returned", success=success)
except KeyboardInterrupt:
# On Windows-detached runs this shouldn't fire (we absorb SIGINT above),

135
hermes_cli/gateway_http.py Normal file
View File

@@ -0,0 +1,135 @@
"""
Shared helper for discovering and communicating with a profile's running
gateway HTTP management API.
Usage
-----
from hermes_cli.gateway_http import get_profile_gateway, call_profile_gateway
info = get_profile_gateway("worker")
if info:
# Gateway is running — talk to it
result = await call_profile_gateway("worker", "GET", "/api/config")
else:
# Not running — fall back to direct file access
The gateway writes ``{HERMES_HOME}/gateway_http.json`` when it starts.
This module reads that file for any profile to obtain the host/port/token.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Token header name — matches the dashboard and the gateway's auth middleware.
_TOKEN_HEADER = "X-Hermes-Session-Token"
def _get_profile_home(profile: Optional[str]) -> Optional[Path]:
"""Resolve a profile name to its HERMES_HOME directory.
Returns None for the default/current profile (callers use get_hermes_home()
directly).
"""
if not profile or profile.lower() in ("default", "current", ""):
return None
try:
from hermes_cli.profiles import get_profile_dir, profile_exists
if not profile_exists(profile):
return None
return get_profile_dir(profile)
except Exception:
return None
def get_profile_gateway(profile: Optional[str] = None) -> Optional[dict[str, Any]]:
"""Return the running gateway's HTTP info for a profile, or None.
Returns a dict with ``base_url``, ``ws_url``, and ``token`` when a gateway
is running for the given profile — or ``None`` when no gateway is up (file
absent, stale PID, or gateway not configured with HTTP).
Callers must fall back to direct file access when this returns ``None``.
:param profile: Profile name, or None/'' for the current default profile.
"""
from gateway.status import read_gateway_http_info
home = _get_profile_home(profile)
return read_gateway_http_info(home)
async def call_profile_gateway(
profile: Optional[str],
method: str,
path: str,
**httpx_kwargs: Any,
) -> Optional[Any]:
"""Call a profile's gateway HTTP API.
Returns the parsed JSON response, or ``None`` when the gateway isn't
running (so callers can fall back to ``_profile_scope``).
Raises ``httpx.HTTPStatusError`` on HTTP 4xx/5xx.
:param profile: Profile name, or None for the default profile.
:param method: HTTP method (GET, POST, PUT, DELETE, PATCH).
:param path: Path including leading slash, e.g. ``"/api/config"``.
:param httpx_kwargs: Extra kwargs forwarded to ``httpx.AsyncClient.request``
(e.g. ``json=...``, ``params=...``).
"""
info = get_profile_gateway(profile)
if not info:
return None
try:
import httpx
except ImportError:
logger.debug("httpx not available; cannot proxy to profile gateway")
return None
url = f"{info['base_url']}{path}"
headers = {_TOKEN_HEADER: info["token"]}
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.request(method, url, headers=headers, **httpx_kwargs)
resp.raise_for_status()
return resp.json() if resp.content else None
except httpx.ConnectError:
# Gateway reported as running but TCP refused — stale PID surviving a
# crash where atexit didn't fire. Don't raise; caller falls back.
logger.debug("Gateway HTTP connect failed for profile %r at %s", profile, url)
return None
except Exception:
raise
def call_profile_gateway_sync(
profile: Optional[str],
method: str,
path: str,
**httpx_kwargs: Any,
) -> Optional[Any]:
"""Synchronous wrapper around ``call_profile_gateway`` for non-async contexts.
Spins up a throwaway event loop. Prefer the async version when already
inside an asyncio context.
"""
import asyncio
try:
asyncio.get_running_loop()
# A loop is already running — can't call asyncio.run() from here.
# Caller is async and should use call_profile_gateway directly.
logger.debug(
"call_profile_gateway_sync called from a running loop; use async version"
)
return None
except RuntimeError:
pass # no running loop — safe to call asyncio.run()
return asyncio.run(call_profile_gateway(profile, method, path, **httpx_kwargs))

View File

@@ -5347,6 +5347,9 @@ def _find_stale_dashboard_pids(
"hermes dashboard",
"hermes_cli.main dashboard",
"hermes_cli/main.py dashboard",
"hermes gateway run",
"hermes_cli.main gateway",
"hermes_cli/main.py gateway",
]
self_pid = os.getpid()
dashboard_pids: list[int] = []

View File

@@ -58,6 +58,28 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
"gateway's exit code. No effect outside an s6 container."
),
)
# HTTP Management API
gateway_run.add_argument(
"--http-port",
type=int,
default=None,
help="HTTP management API port (0 = auto-assign, default: 0)",
)
gateway_run.add_argument(
"--http-host",
default=None,
help="HTTP management API bind host (default: 127.0.0.1)",
)
gateway_run.add_argument(
"--http-token",
default=None,
help="HTTP management API token (auto-generated if not set)",
)
gateway_run.add_argument(
"--no-http",
action="store_true",
help="Disable HTTP management API",
)
add_accept_hooks_flag(gateway_run)
add_accept_hooks_flag(gateway_parser)

View File

@@ -2931,8 +2931,10 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
"confirm_message": warning.message,
}
effective = body.profile or profile
def _apply_assignment():
with _profile_scope(body.profile or profile):
with _profile_scope(effective):
return _apply_model_assignment_sync(
scope, provider, model, task, base_url
)
@@ -8710,6 +8712,31 @@ def _profile_scope(profile: Optional[str]):
reset_hermes_home_override(token)
async def _profile_gateway_write(
profile: Optional[str],
method: str,
path: str,
**httpx_kwargs,
) -> Optional[Any]:
"""Try to proxy a write to a profile's running gateway HTTP API.
Returns the gateway's JSON response when the gateway is running, or
``None`` when it isn't (caller falls back to ``_profile_scope``).
This is the one-line adapter for phase 4c: every write endpoint calls this
first, and only enters ``_profile_scope`` on a ``None`` return.
"""
try:
from hermes_cli.gateway_http import call_profile_gateway
return await call_profile_gateway(profile, method, path, **httpx_kwargs)
except Exception:
_log.debug(
"Gateway write proxy failed for profile=%r %s %s, falling back",
profile, method, path, exc_info=True,
)
return None
class SkillToggle(BaseModel):
name: str
enabled: bool
@@ -8732,7 +8759,15 @@ async def get_skills(profile: Optional[str] = None):
@app.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
with _profile_scope(body.profile or profile):
effective = body.profile or profile
# Try proxying to the profile's running gateway first
gw = await _profile_gateway_write(
effective, "PUT", "/api/skills/toggle",
json={"name": body.name, "enabled": body.enabled},
)
if gw is not None:
return {"ok": True, "name": body.name, "enabled": body.enabled}
with _profile_scope(effective):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
@@ -8790,15 +8825,16 @@ async def get_skill_content(name: str, profile: Optional[str] = None):
@app.post("/api/skills")
async def create_skill(body: SkillCreate):
"""Create a new custom skill (SKILL.md) from the dashboard editor.
Calls the same validated write path as the agent's ``skill_manage``
tool (frontmatter validation, name/category validation, size limit,
optional security scan) — but bypasses the agent write-approval gate:
a write from the authenticated dashboard IS the user acting directly.
"""
"""Create a new custom skill (SKILL.md) from the dashboard editor."""
from tools.skill_manager_tool import _create_skill
gw = await _profile_gateway_write(
body.profile, "POST", "/api/skills",
json={"name": body.name, "content": body.content, "category": body.category},
)
if gw is not None:
_clear_skills_prompt_cache()
return gw
with _profile_scope(body.profile):
result = _create_skill(body.name, body.content, body.category or None)
if not result.get("success"):
@@ -8812,6 +8848,13 @@ async def update_skill_content(body: SkillContentUpdate):
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
from tools.skill_manager_tool import _edit_skill
gw = await _profile_gateway_write(
body.profile, "PUT", "/api/skills/content",
json={"name": body.name, "content": body.content},
)
if gw is not None:
_clear_skills_prompt_cache()
return gw
with _profile_scope(body.profile):
result = _edit_skill(body.name, body.content)
if not result.get("success"):
@@ -8882,16 +8925,23 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile or profile):
effective = body.profile or profile
gw = await _profile_gateway_write(
effective, "POST", f"/api/tools/toolsets/{name}/config",
params={"enabled": str(body.enabled).lower()},
)
if gw is not None:
return {"ok": True, "name": name, "enabled": body.enabled}
with _profile_scope(effective):
config = load_config()
enabled = set(
enabled_set = set(
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
)
if body.enabled:
enabled.add(name)
enabled_set.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, "cli", enabled)
enabled_set.discard(name)
_save_platform_tools(config, "cli", enabled_set)
return {"ok": True, "name": name, "enabled": body.enabled}
@@ -8984,7 +9034,14 @@ async def select_toolset_provider(
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile or profile):
effective = body.profile or profile
gw = await _profile_gateway_write(
effective, "POST", f"/api/tools/toolsets/{name}/provider",
json={"provider": body.provider},
)
if gw is not None:
return {"ok": True, "name": name, "provider": body.provider}
with _profile_scope(effective):
config = load_config()
try:
apply_provider_selection(name, body.provider, config)
@@ -9022,7 +9079,16 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile or profile):
effective = body.profile or profile
# Env writes: each key goes to ~/.hermes/.env; proxy to the gateway so it
# picks up the new values in its live process environment.
gw = await _profile_gateway_write(
effective, "PUT", f"/api/tools/toolsets/{name}/env",
json={"env": body.env},
)
if gw is not None:
return {"ok": True, "name": name, **gw}
with _profile_scope(effective):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
allowed: set[str] = set()
@@ -9134,8 +9200,16 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
parsed = yaml.safe_load(body.yaml_text)
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
with _profile_scope(body.profile or profile):
save_config(parsed)
effective = body.profile or profile
# Try gateway first so the live process picks up config changes immediately.
# Use PUT /api/config/raw which accepts a full YAML string.
gw = await _profile_gateway_write(
effective, "PUT", "/api/config/raw",
params={"yaml_text": body.yaml_text},
)
if gw is None:
with _profile_scope(effective):
save_config(parsed)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
@@ -9650,13 +9724,40 @@ def _resolve_chat_argv(
if sidecar_url:
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
# Profile-scoped chats must NOT attach to the dashboard's in-memory
# gateway — it runs under the dashboard's own profile. Without the
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
# inherits the profile HERMES_HOME set above.
# Profile-scoped chats: prefer attaching to the profile's own running
# gateway (which already has the right HERMES_HOME, config, skills, etc.)
# over the old approach of spawning a fresh tui_gateway.entry subprocess
# with HERMES_HOME env-injected.
#
# When no gateway is running for that profile we fall back to the previous
# behaviour: no HERMES_TUI_GATEWAY_URL, so tui_gateway.entry spawns its
# own instance inheriting the HERMES_HOME we set above.
if profile_dir is None:
# Default/current profile: attach to this dashboard's in-memory gateway.
if gateway_ws_url := _build_gateway_ws_url():
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
else:
# Named profile: try to attach to that profile's running gateway.
try:
from hermes_cli.gateway_http import get_profile_gateway
gw = get_profile_gateway(requested)
if gw:
# Gateway is running for this profile — attach directly.
# Use ?token= on the ws url so it works with our auth middleware.
import urllib.parse as _up
ws_url = gw["ws_url"]
token = gw["token"]
ws_url_with_token = (
ws_url + ("&" if "?" in ws_url else "?") +
_up.urlencode({"token": token})
)
env["HERMES_TUI_GATEWAY_URL"] = ws_url_with_token
# Gateway process owns HERMES_HOME — no need to override it.
env.pop("HERMES_HOME", None)
except Exception:
_log.debug("Failed to look up gateway for profile %r", requested, exc_info=True)
# Fall back: keep HERMES_HOME set, no HERMES_TUI_GATEWAY_URL,
# tui_gateway.entry will spawn its own instance.
return list(argv), str(cwd) if cwd else None, env

View File

@@ -703,7 +703,7 @@ check_git() {
}
# The desktop build runs Vite ^8, which refuses to start on Node outside
# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build`
# `>=26.0.0` — older Node lacks the required features, so `vite build`
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
# app … exit code 1" install failure. Returns 0 when the given `node --version`
# string clears that floor; anything below it is replaced with the Hermes-
@@ -711,11 +711,8 @@ check_git() {
node_satisfies_build() {
local ver="${1#v}"
local major="${ver%%.*}"
local minor="${ver#*.}"; minor="${minor%%.*}"
case "$major" in ''|*[!0-9]*) return 1 ;; esac
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi
if [ "$major" -ge 26 ]; then return 0; fi
return 1
}