mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-17 14:42:06 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-6b48295e
This commit is contained in:
commit
2ecb4e62bb
239 changed files with 18356 additions and 2494 deletions
|
|
@ -31,6 +31,9 @@ logger = logging.getLogger(__name__)
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory names to skip entirely (matched against each path component)
|
||||
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
|
||||
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
|
||||
# are not accidentally excluded.
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
|
|
@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool:
|
|||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
||||
parts = rel_path.parts
|
||||
|
||||
# Any path component matches an excluded dir name
|
||||
for part in parts:
|
||||
if part in _EXCLUDED_DIRS:
|
||||
return True
|
||||
if part not in _EXCLUDED_DIRS:
|
||||
continue
|
||||
# ``hermes-agent`` only matches at the root level (first component).
|
||||
# Nested directories with the same name — e.g.
|
||||
# ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved.
|
||||
if part == "hermes-agent" and part != parts[0]:
|
||||
continue
|
||||
return True
|
||||
|
||||
name = rel_path.name
|
||||
|
||||
|
|
@ -177,10 +185,13 @@ def run_backup(args) -> None:
|
|||
rel_dir = dp.relative_to(hermes_root)
|
||||
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
# ``hermes-agent`` is only pruned at the root level; nested dirs
|
||||
# with the same name (e.g. in skills/) must be preserved.
|
||||
is_root = rel_dir == Path(".")
|
||||
orig_dirnames = dirnames[:]
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _EXCLUDED_DIRS
|
||||
if d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root)
|
||||
]
|
||||
for removed in set(orig_dirnames) - set(dirnames):
|
||||
skipped_dirs.add(str(rel_dir / removed))
|
||||
|
|
@ -211,7 +222,13 @@ def run_backup(args) -> None:
|
|||
try:
|
||||
# Safe copy for SQLite databases (handles WAL mode)
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
|
|
@ -853,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
|
|||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import subprocess
|
|||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
|
|
@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
|||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent"
|
||||
|
||||
|
||||
def _canonical_github_remote(url: str | None) -> str:
|
||||
"""Return ``host/owner/repo`` for common GitHub remote URL forms."""
|
||||
if not url:
|
||||
return ""
|
||||
value = url.strip()
|
||||
if value.startswith("git@github.com:"):
|
||||
value = "github.com/" + value[len("git@github.com:"):]
|
||||
elif value.startswith("ssh://git@github.com/"):
|
||||
value = "github.com/" + value[len("ssh://git@github.com/"):]
|
||||
else:
|
||||
parsed = urlparse(value)
|
||||
if parsed.netloc and parsed.path:
|
||||
value = f"{parsed.netloc}{parsed.path}"
|
||||
value = value.strip().rstrip("/")
|
||||
if value.endswith(".git"):
|
||||
value = value[:-4]
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _is_ssh_remote(url: str | None) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
value = url.strip().lower()
|
||||
return value.startswith("git@") or value.startswith("ssh://")
|
||||
|
||||
|
||||
def _is_official_ssh_remote(url: str | None) -> bool:
|
||||
return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL
|
||||
|
||||
|
||||
def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(cwd),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
|
|
@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]:
|
|||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir)
|
||||
if _is_official_ssh_remote(origin_url):
|
||||
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
return _check_via_rev(head_rev) if head_rev else None
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
|
|
|
|||
|
|
@ -1544,12 +1544,140 @@ class SlashCommandCompleter(Completer):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _tools_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /tools — subcommand + toolset/MCP-server name.
|
||||
|
||||
Handles both ``/tools <tab>`` (suggesting ``list|disable|enable``) and
|
||||
``/tools enable <tab>`` / ``/tools disable <tab>`` (suggesting toolset
|
||||
keys and MCP server prefixes, filtered by current enable state so the
|
||||
user only sees actionable options).
|
||||
"""
|
||||
SUBS = ("list", "disable", "enable")
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
|
||||
# Subcommand stage: zero words typed, or completing the first word.
|
||||
if len(parts) == 0 or (len(parts) == 1 and not trailing_space):
|
||||
partial = sub_text if not trailing_space else ""
|
||||
for sub in SUBS:
|
||||
if sub.startswith(partial.lower()) and sub != partial.lower():
|
||||
yield Completion(sub, start_position=-len(partial), display=sub)
|
||||
return
|
||||
|
||||
subcommand = parts[0].lower()
|
||||
if subcommand not in ("enable", "disable"):
|
||||
return
|
||||
|
||||
partial = "" if trailing_space else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
already = set(parts[1:] if trailing_space else parts[1:-1])
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import (
|
||||
CONFIGURABLE_TOOLSETS,
|
||||
_get_platform_tools,
|
||||
_get_plugin_toolset_keys,
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
enabled = _get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
|
||||
for ts_key, label, _desc in CONFIGURABLE_TOOLSETS:
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta=label,
|
||||
)
|
||||
|
||||
for ts_key in sorted(_get_plugin_toolset_keys()):
|
||||
if ts_key in already or not ts_key.startswith(partial_lower):
|
||||
continue
|
||||
is_on = ts_key in enabled
|
||||
if subcommand == "enable" and is_on:
|
||||
continue
|
||||
if subcommand == "disable" and not is_on:
|
||||
continue
|
||||
yield Completion(
|
||||
ts_key,
|
||||
start_position=-len(partial),
|
||||
display=ts_key,
|
||||
display_meta="plugin toolset",
|
||||
)
|
||||
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
if isinstance(mcp_servers, dict):
|
||||
for server in sorted(mcp_servers):
|
||||
prefix = f"{server}:"
|
||||
if prefix in already or not prefix.startswith(partial_lower):
|
||||
continue
|
||||
yield Completion(
|
||||
prefix,
|
||||
start_position=-len(partial),
|
||||
display=prefix,
|
||||
display_meta=f"MCP server '{server}'",
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _handoff_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield platform completions for /handoff.
|
||||
|
||||
Offers connected (enabled + configured) gateway platforms. A recorded
|
||||
home channel is NOT required to list a platform — it's often learned at
|
||||
runtime — so the meta hints whether one is set yet. Completes only the
|
||||
first arg (the platform); once one is chosen, stop.
|
||||
"""
|
||||
parts = sub_text.split()
|
||||
trailing_space = sub_text.endswith(" ")
|
||||
if len(parts) > 1 or (len(parts) == 1 and trailing_space):
|
||||
return
|
||||
partial = "" if (not parts or trailing_space) else parts[-1]
|
||||
partial_lower = partial.lower()
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
|
||||
gw = load_gateway_config()
|
||||
platforms = gw.get_connected_platforms()
|
||||
except Exception:
|
||||
return
|
||||
for platform in platforms:
|
||||
name = platform.value
|
||||
if not name.startswith(partial_lower):
|
||||
continue
|
||||
try:
|
||||
home = gw.get_home_channel(platform)
|
||||
except Exception:
|
||||
home = None
|
||||
meta = f"→ {home.name}" if home and getattr(home, "name", None) else "send this session here"
|
||||
yield Completion(
|
||||
name,
|
||||
start_position=-len(partial),
|
||||
display=name,
|
||||
display_meta=meta,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _personality_completions(sub_text: str, sub_lower: str):
|
||||
"""Yield completions for /personality from configured personalities."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
personalities = load_config().get("agent", {}).get("personalities", {})
|
||||
# Resolve from the same source the runtime applies personalities —
|
||||
# agent.personalities via the CLI config (which ships the built-ins).
|
||||
# load_config()'s schema has no agent.personalities, so the completer
|
||||
# used to come back empty even with personalities available.
|
||||
from cli import load_cli_config
|
||||
|
||||
personalities = (load_cli_config().get("agent") or {}).get("personalities", {}) or {}
|
||||
if "none".startswith(sub_lower) and "none" != sub_lower:
|
||||
yield Completion(
|
||||
"none",
|
||||
|
|
@ -1602,6 +1730,17 @@ class SlashCommandCompleter(Completer):
|
|||
yield from self._personality_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# /tools needs multi-word completion (subcommand + toolset name)
|
||||
# so it handles both stages itself, bypassing the single-word
|
||||
# SUBCOMMANDS branch below.
|
||||
if base_cmd == "/tools":
|
||||
yield from self._tools_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
if base_cmd == "/handoff":
|
||||
yield from self._handoff_completions(sub_text, sub_lower)
|
||||
return
|
||||
|
||||
# Static subcommand completions
|
||||
if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
|
||||
for sub in SUBCOMMANDS[base_cmd]:
|
||||
|
|
|
|||
|
|
@ -270,6 +270,11 @@ _EXTRA_ENV_KEYS = frozenset({
|
|||
"IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL",
|
||||
"IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD",
|
||||
"TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT",
|
||||
# Deprecated tool-progress env vars — replaced by display.tool_progress in
|
||||
# config.yaml. Kept known here so .env sanitization/reload still handle
|
||||
# them for existing users (gateway reads them as a back-compat fallback),
|
||||
# without surfacing them in user-facing OPTIONAL_ENV_VARS listings.
|
||||
"HERMES_TOOL_PROGRESS", "HERMES_TOOL_PROGRESS_MODE",
|
||||
"WHATSAPP_MODE", "WHATSAPP_ENABLED",
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM",
|
||||
|
|
@ -863,6 +868,19 @@ DEFAULT_CONFIG = {
|
|||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||||
# env var overrides this (build-time/container mechanism).
|
||||
"environment_hint": "",
|
||||
# Coding posture — on interactive coding surfaces (CLI, TUI, desktop
|
||||
# app, ACP) in a code workspace, Hermes adds a coding operating brief
|
||||
# + a live git/workspace snapshot to the system prompt. See
|
||||
# agent/coding_context.py.
|
||||
# "auto" (default) — prompt-only posture when the surface is
|
||||
# interactive AND cwd is a code workspace.
|
||||
# Toolsets are never touched; messaging platforms
|
||||
# unaffected.
|
||||
# "focus" — auto + collapse the toolset to the lean coding
|
||||
# set (+ enabled MCP servers). Explicit opt-in.
|
||||
# "on" — force the prompt posture everywhere.
|
||||
# "off" — disable entirely.
|
||||
"coding_context": "auto",
|
||||
# Staged inactivity warning: send a warning to the user at this
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
|
|
@ -3544,21 +3562,11 @@ OPTIONAL_ENV_VARS = {
|
|||
},
|
||||
# HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated —
|
||||
# now configured via display.tool_progress in config.yaml (off|new|all|verbose).
|
||||
# Gateway falls back to these env vars for backward compatibility.
|
||||
"HERMES_TOOL_PROGRESS": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Tool progress (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
"HERMES_TOOL_PROGRESS_MODE": {
|
||||
"description": "(deprecated) Use display.tool_progress in config.yaml instead",
|
||||
"prompt": "Progress mode (deprecated — use config.yaml)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "setting",
|
||||
},
|
||||
# The gateway still falls back to these env vars for backward compatibility,
|
||||
# so they live in _EXTRA_ENV_KEYS (known to .env sanitization/reload) but
|
||||
# are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing
|
||||
# surfaces (dashboard keys page, setup checklists) and deprecated knobs
|
||||
# shouldn't be offered there.
|
||||
"HERMES_PREFILL_MESSAGES_FILE": {
|
||||
"description": "Path to JSON file with ephemeral prefill messages for few-shot priming",
|
||||
"prompt": "Prefill messages file path",
|
||||
|
|
|
|||
|
|
@ -120,9 +120,6 @@ def cron_list(show_all: bool = False):
|
|||
workdir = job.get("workdir")
|
||||
if workdir:
|
||||
print(f" Workdir: {workdir}")
|
||||
profile = job.get("profile")
|
||||
if profile:
|
||||
print(f" Profile: {profile}")
|
||||
|
||||
# Execution history
|
||||
last_status = job.get("last_status")
|
||||
|
|
@ -221,7 +218,6 @@ def cron_create(args):
|
|||
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
profile=getattr(args, "profile", None),
|
||||
no_agent=getattr(args, "no_agent", False) or None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
@ -239,8 +235,6 @@ def cron_create(args):
|
|||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if job_data.get("workdir"):
|
||||
print(f" Workdir: {job_data['workdir']}")
|
||||
if job_data.get("profile"):
|
||||
print(f" Profile: {job_data['profile']}")
|
||||
print(f" Next run: {result['next_run_at']}")
|
||||
return 0
|
||||
|
||||
|
|
@ -286,7 +280,6 @@ def cron_edit(args):
|
|||
skills=final_skills,
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
profile=getattr(args, "profile", None),
|
||||
no_agent=getattr(args, "no_agent", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
|
|
@ -307,8 +300,6 @@ def cron_edit(args):
|
|||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if updated.get("workdir"):
|
||||
print(f" Workdir: {updated['workdir']}")
|
||||
if updated.get("profile"):
|
||||
print(f" Profile: {updated['profile']}")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2531,6 +2531,65 @@ def systemd_unit_is_current(system: bool = False) -> bool:
|
|||
return norm_installed == norm_expected
|
||||
|
||||
|
||||
def _temp_home_in_service_definition(definition: str) -> str | None:
|
||||
"""Return the temp-dir HERMES_HOME baked into a service definition, or None.
|
||||
|
||||
A generated systemd unit / launchd plist carries the resolved HERMES_HOME
|
||||
in its environment block. If that path lives under the system temp dir,
|
||||
the definition was almost certainly generated by a test/E2E harness that
|
||||
exported a throwaway ``HERMES_HOME=/tmp/...`` — writing it to the real
|
||||
service file silently breaks the user's gateway on the next (re)start:
|
||||
the gateway comes back "active (running)" but pointed at an empty temp
|
||||
home ("No messaging platforms enabled"), deaf to every platform.
|
||||
Seen live 2026-06-11: an E2E guard probe ran ``hermes gateway restart``
|
||||
with ``HERMES_HOME=/tmp/hermes-e2e-<pr>`` exported; the restart path's
|
||||
unit refresh baked the temp path into the production unit and the
|
||||
post-update restart produced a zombie gateway for 7+ hours.
|
||||
|
||||
Matches both systemd ``Environment="HERMES_HOME=..."`` lines and launchd
|
||||
``<key>HERMES_HOME</key><string>...</string>`` pairs.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
|
||||
candidates += re.findall(
|
||||
r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S
|
||||
)
|
||||
temp_roots = {
|
||||
Path(tempfile.gettempdir()).resolve(),
|
||||
Path("/tmp"),
|
||||
Path("/var/tmp"),
|
||||
Path("/private/tmp"),
|
||||
Path("/private/var/tmp"),
|
||||
}
|
||||
for raw in candidates:
|
||||
try:
|
||||
resolved = Path(raw.strip().strip('"')).resolve()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for root in temp_roots:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_temp_home_service_write(definition: str, kind: str) -> bool:
|
||||
"""Refuse (with guidance) when a service definition carries a temp HERMES_HOME."""
|
||||
temp_home = _temp_home_in_service_definition(definition)
|
||||
if temp_home is None:
|
||||
return False
|
||||
print(
|
||||
f"✗ Refusing to write the gateway {kind}: HERMES_HOME resolves to a "
|
||||
f"temporary directory ({temp_home})."
|
||||
)
|
||||
print(
|
||||
" This usually means a test/E2E environment exported HERMES_HOME. "
|
||||
"Unset it (or run from a clean shell) and retry."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
"""Rewrite the installed systemd unit when the generated definition has changed."""
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
|
|
@ -2561,6 +2620,12 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
|||
):
|
||||
return False
|
||||
|
||||
# Structural variant of the same belt: refuse to bake ANY temp-dir
|
||||
# HERMES_HOME into the unit (manual E2E homes like /tmp/hermes-e2e-NNN
|
||||
# don't carry the pytest markers above but poison the unit identically).
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return False
|
||||
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
print(
|
||||
|
|
@ -2729,10 +2794,11 @@ def systemd_install(
|
|||
return
|
||||
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_unit = generate_systemd_unit(system=system, run_as_user=run_as_user)
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return
|
||||
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
|
||||
unit_path.write_text(
|
||||
generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8"
|
||||
)
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
if enable_on_startup:
|
||||
|
|
@ -3067,12 +3133,77 @@ def get_launchd_label() -> str:
|
|||
return f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
|
||||
|
||||
# Cached launchd domain result — probing is cheap but should only run once per
|
||||
# process invocation (each ``hermes gateway start/stop/status`` call).
|
||||
_resolved_launchd_domain: str | None = None
|
||||
|
||||
|
||||
def _launchd_domain() -> str:
|
||||
# The `user/<uid>` domain (vs the older `gui/<uid>`) is reachable from
|
||||
# non-Aqua/background sessions (SSH, headless, login items) and is the only
|
||||
# one that supports service management on macOS 26+. `gui/<uid>` returns
|
||||
# error 125 ("Domain does not support specified action") there. See #23387.
|
||||
return f"user/{os.getuid()}" # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
"""Return the launchd domain that actually manages the gateway service.
|
||||
|
||||
Probes ``gui/<uid>`` first (Aqua sessions), then ``user/<uid>``
|
||||
(Background/SSH sessions). When neither domain contains a loaded
|
||||
service, falls back to ``launchctl managername`` as a heuristic.
|
||||
|
||||
The result is cached for the lifetime of the process so that repeated
|
||||
calls (``start``, ``stop``, ``restart``) use a consistent domain.
|
||||
|
||||
See #40831, #23387.
|
||||
"""
|
||||
global _resolved_launchd_domain
|
||||
if _resolved_launchd_domain is not None:
|
||||
return _resolved_launchd_domain
|
||||
|
||||
uid = os.getuid() # windows-footgun: ok — POSIX launchd (macOS) helper, never invoked on Windows
|
||||
label = get_launchd_label()
|
||||
gui_domain = f"gui/{uid}"
|
||||
user_domain = f"user/{uid}"
|
||||
|
||||
# 1. Probe gui/<uid> first — in Aqua sessions the service is loaded here.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{gui_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 2. Probe user/<uid> — in Background/SSH sessions this is the working domain.
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "print", f"{user_domain}/{label}"],
|
||||
check=True,
|
||||
timeout=5,
|
||||
capture_output=True,
|
||||
)
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 3. Neither domain has the service loaded — use managername as heuristic.
|
||||
# Aqua → gui/<uid>, anything else (Background, loginwindow) → user/<uid>.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "managername"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if "Aqua" in (result.stdout or ""):
|
||||
_resolved_launchd_domain = gui_domain
|
||||
return gui_domain
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# 4. Default to user/<uid> (matches the pre-probing behavior for
|
||||
# Background/SSH sessions and is the recommended domain on macOS 26+).
|
||||
_resolved_launchd_domain = user_domain
|
||||
return user_domain
|
||||
|
||||
|
||||
# On macOS, exit code 125 ("Domain does not support specified action") and
|
||||
|
|
@ -3297,7 +3428,11 @@ def refresh_launchd_plist_if_needed() -> bool:
|
|||
if not plist_path.exists() or launchd_plist_is_current():
|
||||
return False
|
||||
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return False
|
||||
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
label = get_launchd_label()
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(
|
||||
|
|
@ -3330,8 +3465,11 @@ def launchd_install(force: bool = False):
|
|||
return
|
||||
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
plist_path.write_text(new_plist)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
|
|
@ -3377,9 +3515,12 @@ def launchd_start():
|
|||
|
||||
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
|
||||
if not plist_path.exists():
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
sys.exit(1)
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
|
||||
|
|
|
|||
|
|
@ -1623,7 +1623,11 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
|||
npm_cwd = _workspace_root(tui_dir)
|
||||
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
|
||||
# See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
|
||||
# When ui-tui/ has its own package-lock.json (e.g. curl install),
|
||||
# _workspace_root() returns tui_dir itself. Passing --workspace in
|
||||
# that case fails because npm cannot find a workspace named "ui-tui"
|
||||
# inside ui-tui/. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
|
||||
if termux_startup:
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
|
||||
tui_dir,
|
||||
|
|
@ -4661,7 +4665,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
|||
# graph (including apps/desktop with its Electron + node-pty deps) is never
|
||||
# resolved here. Without --workspace the root package.json's apps/* glob
|
||||
# would pull in desktop on every web build. See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
|
||||
# When web/ has its own package-lock.json, _workspace_root() returns
|
||||
# web_dir itself and --workspace would fail. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
|
||||
if _is_termux_startup_environment():
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
|
||||
r1 = _run_npm_install_deterministic(
|
||||
|
|
@ -10234,6 +10240,21 @@ def _report_dashboard_status() -> int:
|
|||
return len(pids)
|
||||
|
||||
|
||||
def _dashboard_listening(host: str, port: int) -> bool:
|
||||
"""True when something is accepting TCP connections at host:port.
|
||||
|
||||
Any listener counts — even a 401 response proves a dashboard is up.
|
||||
Used by the unified profile-launch routing to decide attach-vs-start.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
|
|
@ -10254,6 +10275,65 @@ def cmd_dashboard(args):
|
|||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
# ── Unified profile launch routing ────────────────────────────────
|
||||
# The dashboard is a MACHINE management surface: it can read/write any
|
||||
# profile via the per-request ?profile= scoping. Running one dashboard
|
||||
# per profile just fragments that (port collisions, N processes, and a
|
||||
# "which dashboard am I on?" guessing game). So when a NAMED profile
|
||||
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
|
||||
# profiles/), default to the machine dashboard:
|
||||
# - already running → open the browser at ?profile=<name> and exit
|
||||
# - not running → re-exec as the machine dashboard (pinned to the
|
||||
# default profile so _apply_profile_override can't re-route through
|
||||
# the sticky active_profile file) with the launching profile
|
||||
# preselected in the UI's switcher.
|
||||
# `--isolated` opts out and preserves the old per-profile behavior.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_launch_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
_launch_profile = "default"
|
||||
|
||||
if (
|
||||
_launch_profile not in ("default", "custom")
|
||||
and not getattr(args, "isolated", False)
|
||||
and not getattr(args, "open_profile", "")
|
||||
):
|
||||
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
|
||||
if _dashboard_listening(args.host, args.port):
|
||||
print(f"Machine dashboard already running on port {args.port}.")
|
||||
print(f" Managing profile '{_launch_profile}': {url}")
|
||||
if not args.no_open:
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
f"Routing to the machine dashboard (profile '{_launch_profile}' "
|
||||
f"preselected). Use --isolated for a dedicated per-profile server."
|
||||
)
|
||||
reexec_argv = [
|
||||
sys.executable, "-m", "hermes_cli.main",
|
||||
"-p", "default",
|
||||
"dashboard",
|
||||
"--port", str(args.port),
|
||||
"--host", args.host,
|
||||
"--open-profile", _launch_profile,
|
||||
]
|
||||
if args.no_open:
|
||||
reexec_argv.append("--no-open")
|
||||
if getattr(args, "insecure", False):
|
||||
reexec_argv.append("--insecure")
|
||||
if getattr(args, "skip_build", False):
|
||||
reexec_argv.append("--skip-build")
|
||||
env = os.environ.copy()
|
||||
# Drop the profile HERMES_HOME so the child binds the machine root.
|
||||
env.pop("HERMES_HOME", None)
|
||||
os.execvpe(sys.executable, reexec_argv, env)
|
||||
|
||||
# Attach gui.log early so dashboard startup/build failures are captured in
|
||||
# the same logs directory as every other Hermes surface.
|
||||
try:
|
||||
|
|
@ -10327,6 +10407,7 @@ def cmd_dashboard(args):
|
|||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
initial_profile=getattr(args, "open_profile", "") or "",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -491,15 +491,27 @@ def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> No
|
|||
|
||||
def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, Any]]:
|
||||
requested_norm = _normalize_custom_provider_name(requested_provider or "")
|
||||
if not requested_norm or requested_norm == "custom":
|
||||
if not requested_norm:
|
||||
return None
|
||||
|
||||
# Bare "custom" is normally an incomplete spec — the canonical form is
|
||||
# "custom:<name>" — and is otherwise owned by the model.base_url "bare
|
||||
# custom" trust path. BUT a user may literally name a ``providers:`` (or
|
||||
# legacy ``custom_providers:``) entry "custom" (e.g. ``providers.custom``
|
||||
# pointing at cliproxy). We used to return None here *before* scanning
|
||||
# config, so such an entry was never matched and resolution fell through to
|
||||
# the global default (Codex) — the cause of cron jobs with
|
||||
# ``provider: "custom"`` failing with ``auth_unavailable: providers=codex``.
|
||||
# Fall through to the config scan instead; if no entry is literally named
|
||||
# "custom" it still returns None at the end, preserving the trust path.
|
||||
|
||||
# Raw names should only map to custom providers when they are not already
|
||||
# valid built-in providers or aliases. Explicit menu keys like
|
||||
# ``custom:local`` always target the saved custom provider.
|
||||
# ``custom:local`` always target the saved custom provider. Bare "custom"
|
||||
# is exempt from the shadow check — it is not a built-in to defer to.
|
||||
if requested_norm == "auto":
|
||||
return None
|
||||
if not requested_norm.startswith("custom:"):
|
||||
if requested_norm != "custom" and not requested_norm.startswith("custom:"):
|
||||
try:
|
||||
canonical = auth_mod.resolve_provider(requested_norm)
|
||||
except AuthError:
|
||||
|
|
@ -634,6 +646,20 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
|
|||
return None
|
||||
|
||||
|
||||
def has_named_custom_provider(requested_provider: str) -> bool:
|
||||
"""Return True when config defines a custom provider matching the request.
|
||||
|
||||
Thin public wrapper around :func:`_get_named_custom_provider` so other
|
||||
modules (e.g. the cronjob tool) can decide whether a provider name will
|
||||
actually resolve to a configured ``providers:`` / ``custom_providers:``
|
||||
entry — without reaching into a private helper or duplicating the scan.
|
||||
"""
|
||||
try:
|
||||
return _get_named_custom_provider(requested_provider) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
|
||||
extra_body = custom_provider.get("extra_body")
|
||||
if not isinstance(extra_body, dict) or not extra_body:
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from. Injects AGENTS.md / CLAUDE.md / .cursorrules from that directory and uses it as the cwd for terminal/file/code_exec tools. Omit to preserve old behaviour (no project context files).",
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--profile",
|
||||
help="Hermes profile name to run the job under. Use 'default' for the root profile. Named profiles must already exist. Omit to preserve the scheduler's existing profile.",
|
||||
)
|
||||
|
||||
# cron edit
|
||||
cron_edit = cron_subparsers.add_parser(
|
||||
|
|
@ -138,10 +134,6 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
|
|||
"--workdir",
|
||||
help="Absolute path for the job to run from (injects AGENTS.md etc. and sets terminal cwd). Pass empty string to clear.",
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--profile",
|
||||
help="Hermes profile name to run the job under. Use 'default' for the root profile. Pass empty string to clear.",
|
||||
)
|
||||
|
||||
# lifecycle actions
|
||||
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,26 @@ def build_dashboard_parser(
|
|||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help=(
|
||||
"When launched from a named profile (e.g. `worker dashboard`), run "
|
||||
"a dedicated dashboard server scoped to that profile instead of "
|
||||
"routing to the machine dashboard. Default behavior is unified: "
|
||||
"profile launches attach to (or start) ONE machine-level dashboard "
|
||||
"and preselect the profile in the UI's profile switcher."
|
||||
),
|
||||
)
|
||||
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
|
||||
# preselect the launching profile in the SPA switcher. Hidden from
|
||||
# --help: users get this behavior automatically via `<profile> dashboard`.
|
||||
dashboard_parser.add_argument(
|
||||
"--open-profile",
|
||||
dest="open_profile",
|
||||
default="",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
|
|
|
|||
|
|
@ -1437,6 +1437,10 @@ def _get_platform_tools(
|
|||
continue
|
||||
if ts_def.get("includes"):
|
||||
continue
|
||||
# Posture toolsets (e.g. ``coding``) are session-level selections made
|
||||
# by agent/coding_context.py — not per-platform capabilities to recover.
|
||||
if ts_def.get("posture"):
|
||||
continue
|
||||
ts_tools = set(resolve_toolset(ts_key))
|
||||
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
|
||||
continue
|
||||
|
|
@ -2178,8 +2182,13 @@ def _toolset_needs_configuration_prompt(
|
|||
tts_cfg = config.get("tts", {})
|
||||
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
|
||||
if ts_key == "web":
|
||||
web_cfg = config.get("web", {})
|
||||
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
|
||||
# Web works out of the box via Parallel's free Search MCP (no key), so
|
||||
# don't force setup just because ``web.backend`` is unset — only prompt
|
||||
# when web isn't actually usable (e.g. an explicit backend configured
|
||||
# without its credentials). Lazy import: web_tools is heavy and most
|
||||
# tools_config callers don't need it.
|
||||
from tools.web_tools import check_web_api_key
|
||||
return not check_web_api_key()
|
||||
if ts_key == "browser":
|
||||
browser_cfg = config.get("browser", {})
|
||||
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue