mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-1f7bfa9e
# Conflicts: # cron/scheduler.py # tools/send_message_tool.py
This commit is contained in:
commit
e7fc6450fc
99 changed files with 9609 additions and 1075 deletions
|
|
@ -157,30 +157,54 @@ def _request_gateway_self_restart(pid: int) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def find_gateway_pids(exclude_pids: set | None = None) -> list:
|
||||
def find_gateway_pids(exclude_pids: set | None = None, all_profiles: bool = False) -> list:
|
||||
"""Find PIDs of running gateway processes.
|
||||
|
||||
Args:
|
||||
exclude_pids: PIDs to exclude from the result (e.g. service-managed
|
||||
PIDs that should not be killed during a stale-process sweep).
|
||||
all_profiles: When ``True``, return gateway PIDs across **all**
|
||||
profiles (the pre-7923 global behaviour). ``hermes update``
|
||||
needs this because a code update affects every profile.
|
||||
When ``False`` (default), only PIDs belonging to the current
|
||||
Hermes profile are returned.
|
||||
"""
|
||||
pids = []
|
||||
_exclude = exclude_pids or set()
|
||||
pids = [pid for pid in _get_service_pids() if pid not in _exclude]
|
||||
patterns = [
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli.main --profile",
|
||||
"hermes_cli.main -p",
|
||||
"hermes_cli/main.py gateway",
|
||||
"hermes_cli/main.py --profile",
|
||||
"hermes_cli/main.py -p",
|
||||
"hermes gateway",
|
||||
"gateway/run.py",
|
||||
]
|
||||
current_home = str(get_hermes_home().resolve())
|
||||
current_profile_arg = _profile_arg(current_home)
|
||||
current_profile_name = current_profile_arg.split()[-1] if current_profile_arg else ""
|
||||
|
||||
def _matches_current_profile(command: str) -> bool:
|
||||
if current_profile_name:
|
||||
return (
|
||||
f"--profile {current_profile_name}" in command
|
||||
or f"-p {current_profile_name}" in command
|
||||
or f"HERMES_HOME={current_home}" in command
|
||||
)
|
||||
|
||||
if "--profile " in command or " -p " in command:
|
||||
return False
|
||||
if "HERMES_HOME=" in command and f"HERMES_HOME={current_home}" not in command:
|
||||
return False
|
||||
return True
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
# Windows: use wmic to search command lines
|
||||
result = subprocess.run(
|
||||
["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
# Parse WMIC LIST output: blocks of "CommandLine=...\nProcessId=...\n"
|
||||
current_cmd = ""
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
|
|
@ -188,7 +212,7 @@ def find_gateway_pids(exclude_pids: set | None = None) -> list:
|
|||
current_cmd = line[len("CommandLine="):]
|
||||
elif line.startswith("ProcessId="):
|
||||
pid_str = line[len("ProcessId="):]
|
||||
if any(p in current_cmd for p in patterns):
|
||||
if any(p in current_cmd for p in patterns) and (all_profiles or _matches_current_profile(current_cmd)):
|
||||
try:
|
||||
pid = int(pid_str)
|
||||
if pid != os.getpid() and pid not in pids and pid not in _exclude:
|
||||
|
|
@ -198,41 +222,57 @@ def find_gateway_pids(exclude_pids: set | None = None) -> list:
|
|||
current_cmd = ""
|
||||
else:
|
||||
result = subprocess.run(
|
||||
["ps", "aux"],
|
||||
["ps", "eww", "-ax", "-o", "pid=,command="],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
for line in result.stdout.split('\n'):
|
||||
# Skip grep and current process
|
||||
if 'grep' in line or str(os.getpid()) in line:
|
||||
stripped = line.strip()
|
||||
if not stripped or 'grep' in stripped:
|
||||
continue
|
||||
for pattern in patterns:
|
||||
if pattern in line:
|
||||
parts = line.split()
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
pid = int(parts[1])
|
||||
if pid not in pids and pid not in _exclude:
|
||||
pids.append(pid)
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
except Exception:
|
||||
|
||||
pid = None
|
||||
command = ""
|
||||
|
||||
parts = stripped.split(None, 1)
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
pid = int(parts[0])
|
||||
command = parts[1]
|
||||
except ValueError:
|
||||
pid = None
|
||||
|
||||
if pid is None:
|
||||
aux_parts = stripped.split()
|
||||
if len(aux_parts) > 10 and aux_parts[1].isdigit():
|
||||
pid = int(aux_parts[1])
|
||||
command = " ".join(aux_parts[10:])
|
||||
|
||||
if pid is None:
|
||||
continue
|
||||
if pid == os.getpid() or pid in pids or pid in _exclude:
|
||||
continue
|
||||
if any(pattern in command for pattern in patterns) and (all_profiles or _matches_current_profile(command)):
|
||||
pids.append(pid)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return pids
|
||||
|
||||
|
||||
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None) -> int:
|
||||
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
|
||||
all_profiles: bool = False) -> int:
|
||||
"""Kill any running gateway processes. Returns count killed.
|
||||
|
||||
Args:
|
||||
force: Use the platform's force-kill mechanism instead of graceful terminate.
|
||||
exclude_pids: PIDs to skip (e.g. service-managed PIDs that were just
|
||||
restarted and should not be killed).
|
||||
all_profiles: When ``True``, kill across all profiles. Passed
|
||||
through to :func:`find_gateway_pids`.
|
||||
"""
|
||||
pids = find_gateway_pids(exclude_pids=exclude_pids)
|
||||
pids = find_gateway_pids(exclude_pids=exclude_pids, all_profiles=all_profiles)
|
||||
killed = 0
|
||||
|
||||
for pid in pids:
|
||||
|
|
@ -633,6 +673,17 @@ def print_systemd_linger_guidance() -> None:
|
|||
print(" If you want the gateway user service to survive logout, run:")
|
||||
print(" sudo loginctl enable-linger $USER")
|
||||
|
||||
def _launchd_user_home() -> Path:
|
||||
"""Return the real macOS user home for launchd artifacts.
|
||||
|
||||
Profile-mode Hermes often sets ``HOME`` to a profile-scoped directory, but
|
||||
launchd user agents still live under the actual account home.
|
||||
"""
|
||||
import pwd
|
||||
|
||||
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
||||
|
||||
|
||||
def get_launchd_plist_path() -> Path:
|
||||
"""Return the launchd plist path, scoped per profile.
|
||||
|
||||
|
|
@ -641,7 +692,7 @@ def get_launchd_plist_path() -> Path:
|
|||
"""
|
||||
suffix = _profile_suffix()
|
||||
name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
return Path.home() / "Library" / "LaunchAgents" / f"{name}.plist"
|
||||
return _launchd_user_home() / "Library" / "LaunchAgents" / f"{name}.plist"
|
||||
|
||||
def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
|
|
@ -839,6 +890,25 @@ def _normalize_service_definition(text: str) -> str:
|
|||
return "\n".join(line.rstrip() for line in text.strip().splitlines())
|
||||
|
||||
|
||||
def _normalize_launchd_plist_for_comparison(text: str) -> str:
|
||||
"""Normalize launchd plist text for staleness checks.
|
||||
|
||||
The generated plist intentionally captures a broad PATH assembled from the
|
||||
invoking shell so user-installed tools remain reachable under launchd.
|
||||
That makes raw text comparison unstable across shells, so ignore the PATH
|
||||
payload when deciding whether the installed plist is stale.
|
||||
"""
|
||||
import re
|
||||
|
||||
normalized = _normalize_service_definition(text)
|
||||
return re.sub(
|
||||
r'(<key>PATH</key>\s*<string>)(.*?)(</string>)',
|
||||
r'\1__HERMES_PATH__\3',
|
||||
normalized,
|
||||
flags=re.S,
|
||||
)
|
||||
|
||||
|
||||
def systemd_unit_is_current(system: bool = False) -> bool:
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
if not unit_path.exists():
|
||||
|
|
@ -1220,7 +1290,7 @@ def launchd_plist_is_current() -> bool:
|
|||
|
||||
installed = plist_path.read_text(encoding="utf-8")
|
||||
expected = generate_launchd_plist()
|
||||
return _normalize_service_definition(installed) == _normalize_service_definition(expected)
|
||||
return _normalize_launchd_plist_for_comparison(installed) == _normalize_launchd_plist_for_comparison(expected)
|
||||
|
||||
|
||||
def refresh_launchd_plist_if_needed() -> bool:
|
||||
|
|
@ -1751,6 +1821,37 @@ _PLATFORMS = [
|
|||
"help": "Chat ID for scheduled results and notifications."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "wecom_callback",
|
||||
"label": "WeCom Callback (Self-Built App)",
|
||||
"emoji": "💬",
|
||||
"token_var": "WECOM_CALLBACK_CORP_ID",
|
||||
"setup_instructions": [
|
||||
"1. Go to WeCom Admin Console → Applications → Create Self-Built App",
|
||||
"2. Note the Corp ID (top of admin console) and create a Corp Secret",
|
||||
"3. Under Receive Messages, configure the callback URL to point to your server",
|
||||
"4. Copy the Token and EncodingAESKey from the callback configuration",
|
||||
"5. The adapter runs an HTTP server — ensure the port is reachable from WeCom",
|
||||
"6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "WECOM_CALLBACK_CORP_ID", "prompt": "Corp ID", "password": False,
|
||||
"help": "Your WeCom enterprise Corp ID."},
|
||||
{"name": "WECOM_CALLBACK_CORP_SECRET", "prompt": "Corp Secret", "password": True,
|
||||
"help": "The secret for your self-built application."},
|
||||
{"name": "WECOM_CALLBACK_AGENT_ID", "prompt": "Agent ID", "password": False,
|
||||
"help": "The Agent ID of your self-built application."},
|
||||
{"name": "WECOM_CALLBACK_TOKEN", "prompt": "Callback Token", "password": True,
|
||||
"help": "The Token from your WeCom callback configuration."},
|
||||
{"name": "WECOM_CALLBACK_ENCODING_AES_KEY", "prompt": "Encoding AES Key", "password": True,
|
||||
"help": "The EncodingAESKey from your WeCom callback configuration."},
|
||||
{"name": "WECOM_CALLBACK_PORT", "prompt": "Callback server port (default: 8645)", "password": False,
|
||||
"help": "Port for the HTTP callback server."},
|
||||
{"name": "WECOM_CALLBACK_ALLOWED_USERS", "prompt": "Allowed user IDs (comma-separated, or empty)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Restrict which WeCom users can interact with the app."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "weixin",
|
||||
"label": "Weixin / WeChat",
|
||||
|
|
@ -1981,6 +2082,36 @@ def _setup_whatsapp():
|
|||
cmd_whatsapp(argparse.Namespace())
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via the standard platform setup."""
|
||||
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
|
||||
_setup_standard_platform(email_platform)
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via the standard platform setup."""
|
||||
sms_platform = next(p for p in _PLATFORMS if p["key"] == "sms")
|
||||
_setup_standard_platform(sms_platform)
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk via the standard platform setup."""
|
||||
dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk")
|
||||
_setup_standard_platform(dingtalk_platform)
|
||||
|
||||
|
||||
def _setup_feishu():
|
||||
"""Configure Feishu / Lark via the standard platform setup."""
|
||||
feishu_platform = next(p for p in _PLATFORMS if p["key"] == "feishu")
|
||||
_setup_standard_platform(feishu_platform)
|
||||
|
||||
|
||||
def _setup_wecom():
|
||||
"""Configure WeCom (Enterprise WeChat) via the standard platform setup."""
|
||||
wecom_platform = next(p for p in _PLATFORMS if p["key"] == "wecom")
|
||||
_setup_standard_platform(wecom_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
|
|
@ -2566,7 +2697,7 @@ def gateway_command(args):
|
|||
service_available = True
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
killed = kill_gateway_processes()
|
||||
killed = kill_gateway_processes(all_profiles=True)
|
||||
total = killed + (1 if service_available else 0)
|
||||
if total:
|
||||
print(f"✓ Stopped {total} gateway process(es) across all profiles")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue