Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson
2026-05-10 07:05:16 -04:00
386 changed files with 16873 additions and 1099 deletions
+25 -4
View File
@@ -16,6 +16,19 @@ DEFAULT_CODEX_MODELS: List[str] = [
"gpt-5.4-mini",
"gpt-5.4",
"gpt-5.3-codex",
# gpt-5.3-codex-spark is in research preview and is exposed *only* via
# the Codex CLI / OAuth backend (chatgpt.com/backend-api/codex/models)
# for ChatGPT Pro subscribers. It is NOT available in the public OpenAI
# API, so it intentionally stays out of the "openai" provider catalog
# in hermes_cli/models.py — only the openai-codex (OAuth) provider
# surfaces it. The Codex backend reports ``supported_in_api: false`` for
# this slug; that flag describes API availability, not Codex backend
# availability, so the fetch/cache code paths below intentionally do
# not filter on it. PR #12994 removed this entry on the assumption it
# was unsupported — that was wrong; restored here. Keep it in the
# curated fallback so Pro users still see Spark in `/model` when live
# discovery is unavailable (offline first run, transient API failure).
"gpt-5.3-codex-spark",
"gpt-5.2-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
@@ -26,6 +39,11 @@ _FORWARD_COMPAT_TEMPLATE_MODELS: List[tuple[str, tuple[str, ...]]] = [
("gpt-5.4-mini", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.4", ("gpt-5.3-codex", "gpt-5.2-codex")),
("gpt-5.3-codex", ("gpt-5.2-codex",)),
# Surface Spark whenever any compatible Codex template is present so
# accounts hitting the live endpoint with an older lineup still see
# Spark in the picker. Backend gates real availability by ChatGPT Pro
# entitlement; Hermes does not.
("gpt-5.3-codex-spark", ("gpt-5.3-codex", "gpt-5.2-codex")),
]
@@ -78,8 +96,10 @@ def _fetch_models_from_api(access_token: str) -> List[str]:
if not isinstance(slug, str) or not slug.strip():
continue
slug = slug.strip()
if item.get("supported_in_api") is False:
continue
# Codex CLI's catalog uses ``supported_in_api`` for the public OpenAI
# API, not for the OAuth-backed Codex backend that this provider uses.
# Some valid Codex CLI models (for example gpt-5.3-codex-spark) are
# marked false here but are still accepted by the Codex route.
visibility = item.get("visibility", "")
if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
continue
@@ -128,8 +148,9 @@ def _read_cache_models(codex_home: Path) -> List[str]:
if not isinstance(slug, str) or not slug.strip():
continue
slug = slug.strip()
if item.get("supported_in_api") is False:
continue
# Do not filter on ``supported_in_api`` here. It describes the
# public OpenAI API, while Hermes openai-codex talks to the same
# OAuth-backed Codex backend as Codex CLI.
visibility = item.get("visibility")
if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"):
continue
+3 -3
View File
@@ -216,9 +216,9 @@ _hermes() {{
typeset -A opt_args
_arguments -C \\
'(-h --help){{-h,--help}}[Show help and exit]' \\
'(-V --version){{-V,--version}}[Show version and exit]' \\
'(-p --profile){{-p,--profile}}[Profile name]:profile:_hermes_profiles' \\
'(-)'{{-h,--help}}'[Show help and exit]' \\
'(-)'{{-V,--version}}'[Show version and exit]' \\
'(-)'{{-p,--profile}}'[Profile name]:profile:_hermes_profiles' \\
'1:command:->commands' \\
'*::arg:->args'
+39
View File
@@ -691,9 +691,18 @@ DEFAULT_CONFIG = {
# See: https://openrouter.ai/docs/guides/features/response-caching
# response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).
# Default 300 (5 minutes). Only used when response_cache is enabled.
# min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).
# Only applied when model.model is "openrouter/pareto-code". Higher
# values route to stronger (more expensive) coders; lower values open
# up cheaper, faster options. Default 0.65 lands on the mid-tier
# coder on the current Pareto frontier. Empty string = let OpenRouter
# pick the strongest available coder (router's documented default
# when the plugins block is omitted).
# See: https://openrouter.ai/docs/guides/routing/routers/pareto-router
"openrouter": {
"response_cache": True,
"response_cache_ttl": 300,
"min_coding_score": 0.65,
},
# AWS Bedrock provider configuration.
@@ -722,6 +731,26 @@ DEFAULT_CONFIG = {
# Empty model = use provider's default auxiliary model.
# All tasks fall back to openrouter:google/gemini-3-flash-preview if
# the configured provider is unavailable.
#
# extra_body: forwarded verbatim as request body fields on every aux call
# for that task. Use this to set provider-specific knobs (independent of
# main-agent settings). On OpenRouter you can set provider routing prefs
# and the Pareto Code coding-score floor here. Example:
#
# auxiliary:
# compression:
# provider: openrouter
# model: openrouter/pareto-code
# extra_body:
# provider: # OpenRouter provider routing
# order: [anthropic, google]
# sort: throughput # or price | latency
# plugins: # OpenRouter Pareto Code router
# - id: pareto-router
# min_coding_score: 0.5
#
# Each aux task is independent — main-agent provider_routing and
# openrouter.min_coding_score do NOT propagate to aux calls by design.
"auxiliary": {
"vision": {
"provider": "auto", # auto | openrouter | nous | codex | custom
@@ -1210,6 +1239,15 @@ DEFAULT_CONFIG = {
# "Always Approve" to silence the prompt permanently; that flips
# this key to false.
"mcp_reload_confirm": True,
# When true, destructive session slash commands (/clear, /new, /reset,
# /undo) ask the user to confirm before discarding conversation state.
# Three-option prompt (Approve Once / Always Approve / Cancel) routed
# through tools.slash_confirm — native yes/no buttons on Telegram,
# Discord, and Slack; text fallback elsewhere. Users click "Always
# Approve" to silence the prompt permanently; that flips this key to
# false. TUI has its own modal overlay (HERMES_TUI_NO_CONFIRM=1 to
# opt out there).
"destructive_slash_confirm": True,
},
# Permanently allowed dangerous command patterns (added via "always" approval)
@@ -4812,6 +4850,7 @@ def set_config_value(key: str, value: str):
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
+10 -1
View File
@@ -55,7 +55,16 @@ def _cmd_status(args) -> int:
print(f"curator: {status_line}")
print(f" runs: {runs}")
print(f" last run: {_fmt_ts(last_run)}")
print(f" last summary: {summary}")
# Summary may be multi-line when the curator archived skills (the rename
# map gets appended as `name → umbrella` lines). Indent continuation
# lines so the block reads as one logical field.
if "\n" in summary:
first, *rest = summary.splitlines()
print(f" last summary: {first}")
for line in rest:
print(f" {line}")
else:
print(f" last summary: {summary}")
_report = state.get("last_report_path")
if _report:
suffix = "" if Path(_report).exists() else " (missing)"
+317 -146
View File
@@ -245,15 +245,31 @@ def _build_apikey_providers_list() -> list:
}
for _label, _canonical in _name_to_canonical.items():
_known_canonical.add(_canonical)
# Providers that already have a dedicated health check above the generic
# API-key loop (with custom headers/auth). Skip their pluggable profiles
# here so the generic Bearer-auth loop doesn't run a duplicate, broken
# check (e.g. Anthropic native API requires x-api-key, not Bearer).
_dedicated_canonical = {"anthropic", "openrouter", "bedrock"}
_known_canonical.update(_dedicated_canonical)
try:
from providers import list_providers
from providers.base import ProviderProfile as _PP
try:
from hermes_cli.providers import normalize_provider as _normalize_provider
except Exception: # pragma: no cover - normalization is best-effort
def _normalize_provider(_name: str) -> str:
return (_name or "").strip().lower()
for _pp in list_providers():
if not isinstance(_pp, _PP) or _pp.auth_type != "api_key" or not _pp.env_vars:
continue
_label = _pp.display_name or _pp.name
if _label in _known_names or _pp.name in _known_canonical:
continue
_candidates = {_normalize_provider(_pp.name)}
for _alias in (_pp.aliases or ()):
_candidates.add(_normalize_provider(_alias))
if _candidates & _dedicated_canonical:
continue
# Separate API-key vars from base-URL override vars — the health-check
# loop sends the first found value as Authorization: Bearer, so a URL
# string must never be picked.
@@ -1166,44 +1182,92 @@ def run_doctor(args):
# =========================================================================
print()
print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD))
openrouter_key = os.getenv("OPENROUTER_API_KEY")
if openrouter_key:
print(" Checking OpenRouter API...", end="", flush=True)
# Refactor: every connectivity probe below is HTTP-bound and fully
# independent. Running them in series spent ~5s wall on a typical
# workstation (2s of that was boto3's IMDS lookup for AWS credentials,
# which times out unless you're actually on EC2). Threading them with
# a small executor pool collapses the section to roughly the slowest
# single probe — about 2s — without changing the output format.
#
# Each ``_probe_*`` helper is a pure function: takes its inputs,
# makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying
# the line(s) to print and any issue strings to append. No globals,
# no shared mutable state, no printing inside the workers.
import concurrent.futures as _futures
from collections import namedtuple as _namedtuple
_ConnectivityResult = _namedtuple(
"_ConnectivityResult", ["label", "lines", "issues"]
)
_probes: list = [] # list of (label, callable) submitted in display order
def _probe_openrouter() -> _ConnectivityResult:
key = os.getenv("OPENROUTER_API_KEY")
if not key:
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.YELLOW), "OpenRouter API",
color("(not configured)", Colors.DIM))],
[],
)
try:
import httpx
response = httpx.get(
r = httpx.get(
OPENROUTER_MODELS_URL,
headers={"Authorization": f"Bearer {openrouter_key}"},
timeout=10
headers={"Authorization": f"Bearer {key}"},
timeout=10,
)
if response.status_code == 200:
print(f"\r {color('', Colors.GREEN)} OpenRouter API ")
elif response.status_code == 401:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} ")
issues.append("Check OPENROUTER_API_KEY in .env")
elif response.status_code == 402:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}")
issues.append(
"OpenRouter account has insufficient credits. "
"Fix: run 'hermes config set model.provider <provider>' to switch providers, "
"or fund your OpenRouter account at https://openrouter.ai/settings/credits"
if r.status_code == 200:
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.GREEN), "OpenRouter API", "")],
[],
)
elif response.status_code == 429:
print(f"\r {color('', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} ")
issues.append("OpenRouter rate limit hit — consider switching to a different provider or waiting")
else:
print(f"\r {color('', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} ")
if r.status_code == 401:
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.RED), "OpenRouter API",
color("(invalid API key)", Colors.DIM))],
["Check OPENROUTER_API_KEY in .env"],
)
if r.status_code == 402:
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.RED), "OpenRouter API",
color("(out of credits — payment required)", Colors.DIM))],
["OpenRouter account has insufficient credits. "
"Fix: run 'hermes config set model.provider <provider>' "
"to switch providers, or fund your OpenRouter account "
"at https://openrouter.ai/settings/credits"],
)
if r.status_code == 429:
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.RED), "OpenRouter API",
color("(rate limited)", Colors.DIM))],
["OpenRouter rate limit hit — consider switching to "
"a different provider or waiting"],
)
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.RED), "OpenRouter API",
color(f"(HTTP {r.status_code})", Colors.DIM))],
[],
)
except Exception as e:
print(f"\r {color('', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} ")
issues.append("Check network connectivity")
else:
check_warn("OpenRouter API", "(not configured)")
from hermes_cli.auth import get_anthropic_key
anthropic_key = get_anthropic_key()
if anthropic_key:
print(" Checking Anthropic API...", end="", flush=True)
return _ConnectivityResult(
"OpenRouter API",
[(color("", Colors.RED), "OpenRouter API",
color(f"({e})", Colors.DIM))],
["Check network connectivity"],
)
def _probe_anthropic() -> _ConnectivityResult:
from hermes_cli.auth import get_anthropic_key
key = get_anthropic_key()
if not key:
return _ConnectivityResult("Anthropic API", [], [])
try:
import httpx
from agent.anthropic_adapter import (
@@ -1212,140 +1276,247 @@ def run_doctor(args):
_OAUTH_ONLY_BETAS,
_CONTEXT_1M_BETA,
)
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(anthropic_key)
is_oauth = _is_oauth_token(key)
if is_oauth:
headers["Authorization"] = f"Bearer {anthropic_key}"
headers["Authorization"] = f"Bearer {key}"
headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
else:
headers["x-api-key"] = anthropic_key
response = httpx.get(
headers["x-api-key"] = key
r = httpx.get(
"https://api.anthropic.com/v1/models",
headers=headers,
timeout=10
headers=headers, timeout=10,
)
# Reactive recovery: OAuth subscriptions that don't include 1M
# context reject the request with 400 "long context beta is not
# yet available for this subscription". Retry once with that
# beta stripped so the doctor check doesn't falsely report the
# Anthropic API as unreachable for those users.
# Reactive recovery: OAuth subscriptions without 1M context reject the
# request with 400 "long context beta is not yet available for this
# subscription". Retry once with that beta stripped so the doctor
# check doesn't falsely report Anthropic as unreachable.
if (
is_oauth
and response.status_code == 400
and "long context beta" in response.text.lower()
and "not yet available" in response.text.lower()
and r.status_code == 400
and "long context beta" in r.text.lower()
and "not yet available" in r.text.lower()
):
headers["anthropic-beta"] = ",".join(
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
[b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]
+ list(_OAUTH_ONLY_BETAS)
)
response = httpx.get(
r = httpx.get(
"https://api.anthropic.com/v1/models",
headers=headers,
timeout=10,
headers=headers, timeout=10,
)
if response.status_code == 200:
print(f"\r {color('', Colors.GREEN)} Anthropic API ")
elif response.status_code == 401:
print(f"\r {color('', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} ")
else:
msg = "(couldn't verify)"
print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} ")
if r.status_code == 200:
return _ConnectivityResult(
"Anthropic API",
[(color("", Colors.GREEN), "Anthropic API", "")],
[],
)
if r.status_code == 401:
return _ConnectivityResult(
"Anthropic API",
[(color("", Colors.RED), "Anthropic API",
color("(invalid API key)", Colors.DIM))],
[],
)
return _ConnectivityResult(
"Anthropic API",
[(color("", Colors.YELLOW), "Anthropic API",
color("(couldn't verify)", Colors.DIM))],
[],
)
except Exception as e:
print(f"\r {color('', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} ")
return _ConnectivityResult(
"Anthropic API",
[(color("", Colors.YELLOW), "Anthropic API",
color(f"({e})", Colors.DIM))],
[],
)
def _probe_apikey_provider(pname, env_vars, default_url, base_env,
supports_health_check) -> _ConnectivityResult:
key = ""
for ev in env_vars:
key = os.getenv(ev, "")
if key:
break
if not key:
return _ConnectivityResult(pname, [], [])
label = pname.ljust(20)
if not supports_health_check:
return _ConnectivityResult(
pname,
[(color("", Colors.GREEN), label,
color("(key configured)", Colors.DIM))],
[],
)
try:
import httpx
base = os.getenv(base_env, "") if base_env else ""
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1
# (OpenAI-compat surface, which exposes /models for health check).
if not base and key.startswith("sk-kimi-"):
base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic, api.kimi.com/coding
# with no /v1) don't support /models. Rewrite to OpenAI-compat
# /v1 surface for health checks.
if base and base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
base = _to_openai_base_url(base)
if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"):
base = base.rstrip("/") + "/v1"
url = (base.rstrip("/") + "/models") if base else default_url
headers = {
"Authorization": f"Bearer {key}",
"User-Agent": _HERMES_USER_AGENT,
}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
r = httpx.get(url, headers=headers, timeout=10)
if (
pname == "Alibaba/DashScope"
and not base
and r.status_code == 401
):
r = httpx.get(
"https://dashscope.aliyuncs.com/compatible-mode/v1/models",
headers=headers, timeout=10,
)
if r.status_code == 200:
return _ConnectivityResult(
pname,
[(color("", Colors.GREEN), label, "")],
[],
)
if r.status_code == 401:
return _ConnectivityResult(
pname,
[(color("", Colors.RED), label,
color("(invalid API key)", Colors.DIM))],
[f"Check {env_vars[0]} in .env"],
)
return _ConnectivityResult(
pname,
[(color("", Colors.YELLOW), label,
color(f"(HTTP {r.status_code})", Colors.DIM))],
[],
)
except Exception as e:
return _ConnectivityResult(
pname,
[(color("", Colors.YELLOW), label,
color(f"({e})", Colors.DIM))],
[],
)
def _probe_bedrock() -> _ConnectivityResult:
try:
from agent.bedrock_adapter import (
has_aws_credentials,
resolve_aws_auth_env_var,
resolve_bedrock_region,
)
except ImportError:
return _ConnectivityResult("AWS Bedrock", [], [])
if not has_aws_credentials():
return _ConnectivityResult("AWS Bedrock", [], [])
auth_var = resolve_aws_auth_env_var()
region = resolve_bedrock_region()
label = "AWS Bedrock".ljust(20)
try:
import boto3
from botocore.config import Config as _BotoConfig
# Trim retries on the actual Bedrock API call so a transient
# failure doesn't pad the doctor run by 30+ seconds.
cfg = _BotoConfig(
connect_timeout=5,
read_timeout=10,
retries={"max_attempts": 1},
)
client = boto3.client("bedrock", region_name=region, config=cfg)
resp = client.list_foundation_models()
n = len(resp.get("modelSummaries", []))
return _ConnectivityResult(
"AWS Bedrock",
[(color("", Colors.GREEN), label,
color(f"({auth_var}, {region}, {n} models)", Colors.DIM))],
[],
)
except ImportError:
return _ConnectivityResult(
"AWS Bedrock",
[(color("", Colors.YELLOW), label,
color(f"(boto3 not installed — {sys.executable} -m pip install boto3)",
Colors.DIM))],
[f"Install boto3 for Bedrock: {sys.executable} -m pip install boto3"],
)
except Exception as e:
err_name = type(e).__name__
return _ConnectivityResult(
"AWS Bedrock",
[(color("", Colors.YELLOW), label,
color(f"({err_name}: {e})", Colors.DIM))],
[f"AWS Bedrock: {err_name} — check IAM permissions for "
f"bedrock:ListFoundationModels"],
)
# Build the probe submission list in display order
_probes.append(("OpenRouter API", _probe_openrouter))
_probes.append(("Anthropic API", _probe_anthropic))
# -- API-key providers --
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
# If supports_models_endpoint is False, we skip the health check and just show "configured"
# Cached at module level after first build — profiles auto-extend it.
global _APIKEY_PROVIDERS_CACHE
if _APIKEY_PROVIDERS_CACHE is None:
_APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()
_apikey_providers = _APIKEY_PROVIDERS_CACHE
for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
_key = ""
for _ev in _env_vars:
_key = os.getenv(_ev, "")
if _key:
break
if _key:
_label = _pname.ljust(20)
# Some providers (like MiniMax) don't support /models endpoint
if not _supports_health_check:
print(f" {color('', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}")
continue
print(f" Checking {_pname} API...", end="", flush=True)
try:
import httpx
_base = os.getenv(_base_env, "") if _base_env else ""
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1
# (OpenAI-compat surface, which exposes /models for health check).
if not _base and _key.startswith("sk-kimi-"):
_base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic, api.kimi.com/coding
# with no /v1) don't support /models. Rewrite to the OpenAI-compat
# /v1 surface for health checks.
if _base and _base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
_base = _to_openai_base_url(_base)
if base_url_host_matches(_base, "api.kimi.com") and _base.rstrip("/").endswith("/coding"):
_base = _base.rstrip("/") + "/v1"
_url = (_base.rstrip("/") + "/models") if _base else _default_url
_headers = {
"Authorization": f"Bearer {_key}",
"User-Agent": _HERMES_USER_AGENT,
}
if base_url_host_matches(_base, "api.kimi.com"):
_headers["User-Agent"] = "claude-code/0.1.0"
_resp = httpx.get(
_url,
headers=_headers,
timeout=10,
)
if (
_pname == "Alibaba/DashScope"
and not _base
and _resp.status_code == 401
):
_resp = httpx.get(
"https://dashscope.aliyuncs.com/compatible-mode/v1/models",
headers=_headers,
timeout=10,
)
if _resp.status_code == 200:
print(f"\r {color('', Colors.GREEN)} {_label} ")
elif _resp.status_code == 401:
print(f"\r {color('', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} ")
issues.append(f"Check {_env_vars[0]} in .env")
else:
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} ")
except Exception as _e:
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} ")
for _entry in _APIKEY_PROVIDERS_CACHE:
_pname, _env_vars, _default_url, _base_env, _supports = _entry
# Capture loop vars by binding default args — without this, all closures
# would share the final iteration's values and every probe would hit
# the last provider's URL.
_probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,
b=_base_env, s=_supports:
_probe_apikey_provider(p, e, u, b, s)))
# -- AWS Bedrock --
# Bedrock uses the AWS SDK credential chain, not API keys.
_probes.append(("AWS Bedrock", _probe_bedrock))
# Print a single status line so users see something happening, then
# fan out. ``\r`` clears it once the first real result line lands.
print(f" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}",
end="", flush=True)
# Disable boto3's EC2 instance-metadata-service probe for the duration
# of the parallel block. boto's default credential chain tries
# 169.254.169.254 with a multi-second timeout when we're not on EC2,
# which dominated the section's wall time before this fix
# (~2s on a developer laptop, even with the rest parallelized).
# Set on the parent thread before submitting work so the env-var
# mutation never races with another worker. has_aws_credentials() in
# the bedrock probe already gates on real env-var creds, so IMDS is
# never the legitimate source for `hermes doctor`.
_imds_prev = os.environ.get("AWS_EC2_METADATA_DISABLED")
os.environ["AWS_EC2_METADATA_DISABLED"] = "true"
try:
from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
if has_aws_credentials():
_auth_var = resolve_aws_auth_env_var()
_region = resolve_bedrock_region()
_label = "AWS Bedrock".ljust(20)
print(f" Checking AWS Bedrock...", end="", flush=True)
try:
import boto3
_br_client = boto3.client("bedrock", region_name=_region)
_br_resp = _br_client.list_foundation_models()
_model_count = len(_br_resp.get("modelSummaries", []))
print(f"\r {color('', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} ")
except ImportError:
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} ")
issues.append(f"Install boto3 for Bedrock: {sys.executable} -m pip install boto3")
except Exception as _e:
_err_name = type(_e).__name__
print(f"\r {color('', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} ")
issues.append(f"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels")
except ImportError:
pass # bedrock_adapter not available — skip silently
# 8 workers is plenty — each probe is a single HTTP call plus a TLS
# handshake. More than that wastes thread-startup cost and risks
# noisy output if anything ever printed from inside a worker.
with _futures.ThreadPoolExecutor(max_workers=8,
thread_name_prefix="doctor-probe") as _ex:
_futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]
_results = [_f.result() for _f in _futures_in_order]
finally:
if _imds_prev is None:
os.environ.pop("AWS_EC2_METADATA_DISABLED", None)
else:
os.environ["AWS_EC2_METADATA_DISABLED"] = _imds_prev
# Clear the "Running …" line and print all results in submission order.
print("\r" + " " * 70 + "\r", end="")
for _r in _results:
for _glyph, _label, _detail in _r.lines:
if _detail:
print(f" {_glyph} {_label} {_detail}")
else:
print(f" {_glyph} {_label}")
for _issue in _r.issues:
issues.append(_issue)
# =========================================================================
# Check: Submodules
+175 -57
View File
@@ -394,42 +394,68 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
pass
current_cmd = ""
else:
result = subprocess.run(
["ps", "-A", "eww", "-o", "pid=,command="],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return []
for line in result.stdout.split("\n"):
stripped = line.strip()
if not stripped or "grep" in stripped:
continue
# Try /proc first (works in Docker without procps installed),
# fall back to ps -A eww.
_found_via_proc = False
if os.path.isdir("/proc"):
try:
my_pid = os.getpid()
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
pid = int(entry)
if pid == my_pid or pid in exclude_pids:
continue
try:
cmdline = open(f"/proc/{pid}/cmdline", "rb").read().decode("utf-8", errors="replace")
cmdline = cmdline.replace("\x00", " ")
if any(p in cmdline for p in patterns) and (
all_profiles or _matches_current_profile(cmdline)
):
_append_unique_pid(pids, pid, exclude_pids)
except (OSError, PermissionError):
continue
_found_via_proc = True
except Exception:
pass
pid = None
command = ""
if not _found_via_proc:
result = subprocess.run(
["ps", "-A", "eww", "-o", "pid=,command="],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return []
for line in result.stdout.split("\n"):
stripped = line.strip()
if not stripped or "grep" in stripped:
continue
parts = stripped.split(None, 1)
if len(parts) == 2:
try:
pid = int(parts[0])
command = parts[1]
except ValueError:
pid = None
pid = None
command = ""
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:])
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:
continue
if any(pattern in command for pattern in patterns) and (
all_profiles or _matches_current_profile(command)
):
_append_unique_pid(pids, pid, exclude_pids)
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 any(pattern in command for pattern in patterns) and (
all_profiles or _matches_current_profile(command)
):
_append_unique_pid(pids, pid, exclude_pids)
except (OSError, subprocess.TimeoutExpired):
return []
@@ -635,6 +661,66 @@ def _probe_systemd_service_running(system: bool = False) -> tuple[bool, bool]:
return selected_system, result.stdout.strip() == "active"
def _read_systemd_unit_environment(system: bool = False) -> dict[str, str]:
"""Parse the gateway unit's ``Environment=`` directives.
``systemctl show -p Environment`` returns a single line of
space-separated ``KEY=VALUE`` pairs; values are not quoted in the output
even when the unit file quoted them. We split on whitespace and ``=``.
"""
selected_system = _select_systemd_scope(system)
try:
result = _run_systemctl(
[
"show",
get_service_name(),
"--no-pager",
"--property",
"Environment",
],
system=selected_system,
capture_output=True,
text=True,
timeout=10,
)
except (RuntimeError, subprocess.TimeoutExpired, OSError):
return {}
if result.returncode != 0:
return {}
parsed: dict[str, str] = {}
for line in result.stdout.splitlines():
if not line.startswith("Environment="):
continue
body = line[len("Environment="):].strip()
for token in body.split():
if "=" not in token:
continue
key, value = token.split("=", 1)
parsed[key] = value
return parsed
def _sync_hermes_home_from_systemd_unit(system: bool) -> None:
"""When acting on a system-scope unit, adopt its ``HERMES_HOME``.
Under ``sudo``, ``HERMES_HOME`` is stripped and ``HOME=/root``, so
:func:`get_hermes_home` falls back to ``/root/.hermes`` — the wrong
profile. The unit file pins ``HERMES_HOME`` for the actual gateway
process, so we mirror that into our own environment to make
``read_runtime_status`` / ``get_running_pid`` read the correct files.
"""
if not system:
return
env = _read_systemd_unit_environment(system=True)
unit_home = env.get("HERMES_HOME", "").strip()
if not unit_home:
return
current = os.environ.get("HERMES_HOME", "").strip()
if current == unit_home:
return
os.environ["HERMES_HOME"] = unit_home
def _read_systemd_unit_properties(
system: bool = False,
properties: tuple[str, ...] = (
@@ -1141,6 +1227,27 @@ def is_windows() -> bool:
return sys.platform == 'win32'
def _windows_gateway_should_absorb_console_controls() -> bool:
"""Return True for detached Windows gateway runs that should ignore Ctrl+C.
Foreground ``hermes gateway run`` must remain interruptible from
PowerShell/CMD. Detached service-style launches opt in via
``HERMES_GATEWAY_DETACHED=1``; older wrappers without the env marker are
treated as detached when no interactive stdin is attached.
"""
if not is_windows():
return False
detached = os.getenv("HERMES_GATEWAY_DETACHED", "").strip().lower()
if detached in {"1", "true", "yes", "on"}:
return True
try:
return not bool(sys.stdin and sys.stdin.isatty())
except (ValueError, OSError):
return True
# =============================================================================
# Service Configuration
# =============================================================================
@@ -2149,7 +2256,30 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
return False
expected_user = _read_systemd_user_from_unit(unit_path) if system else None
unit_path.write_text(generate_systemd_unit(system=system, run_as_user=expected_user), encoding="utf-8")
new_unit = generate_systemd_unit(system=system, run_as_user=expected_user)
# ── Test-environment safety belt ─────────────────────────────────────
# The user-scope unit path resolves under ``Path.home()``, which is NOT
# sandboxed by the test conftest (only HERMES_HOME is). If a test
# exercises ``run_gateway()`` with a pytest-tmp HERMES_HOME, the freshly
# generated unit bakes that ``/tmp/pytest-of-.../hermes_test`` path into
# ``Environment="HERMES_HOME=..."``. Writing that to the developer's
# real user systemd unit file silently breaks their gateway on the next
# reboot (systemd loads the polluted env, the gateway looks at an empty
# tmp dir, and Telegram/Discord/etc. all show as "not configured").
# Refuse to write when the generated unit references a pytest tmpdir.
# Detection sniffs the unit body — tests that legitimately exercise the
# refresh flow patch ``generate_systemd_unit`` to return synthetic
# content (``"new unit\n"``) which doesn't contain these markers and
# still works.
if not system and (
"/pytest-of-" in new_unit
or "/hermes_test\"" in new_unit
or "/hermes_test/" in new_unit
):
return False
unit_path.write_text(new_unit, encoding="utf-8")
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
print(f"↻ Updated gateway {_service_scope_label(system)} service definition to match the current Hermes install")
return True
@@ -2380,6 +2510,7 @@ def systemd_stop(system: bool = False):
if system:
_require_root_for_system_service("stop")
_require_service_installed("stop", system=system)
_sync_hermes_home_from_systemd_unit(system=system)
try:
from gateway.status import get_running_pid, write_planned_stop_marker
pid = get_running_pid(cleanup_stale=False)
@@ -2408,6 +2539,7 @@ def systemd_restart(system: bool = False):
_preflight_user_systemd()
_require_service_installed("restart", system=system)
refresh_systemd_unit_if_needed(system=system)
_sync_hermes_home_from_systemd_unit(system=system)
from gateway.status import get_running_pid
pid = get_running_pid() or _systemd_main_pid(system=system)
@@ -2503,6 +2635,8 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}")
return
_sync_hermes_home_from_systemd_unit(system=system)
if has_conflicting_systemd_units():
print_systemd_scope_conflict_warning()
print()
@@ -2978,34 +3112,17 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
_guard_official_docker_root_gateway()
sys.path.insert(0, str(PROJECT_ROOT))
# On Windows, when the gateway is launched as a detached background
# process (via ``hermes gateway install`` → Scheduled Task / Startup
# folder / direct pythonw.exe spawn) there is no console attached. In
# that case Windows can still deliver CTRL_C_EVENT / CTRL_BREAK_EVENT
# to the process group under some circumstances (e.g. when *another*
# process in the same group sends one), which Python 3.11 translates
# into KeyboardInterrupt inside asyncio.run(). The outer handler below
# catches that and exits cleanly — silently killing the gateway. On
# detached boots we must absorb those spurious signals so the gateway
# stays alive; real user Ctrl+C still comes through prompt_toolkit /
# the asyncio signal handler when running in a real console.
#
# IMPORTANT lesson (May 2026): we originally gated this on "stdin is
# NOT a TTY" assuming only detached pythonw runs would be vulnerable.
# Wrong. When the user runs `hermes gateway start` from a PowerShell
# console, the gateway inherits that console and stdin IS a TTY —
# but it's STILL vulnerable to CTRL_C_EVENT broadcast by any sibling
# `hermes` invocation (like `hermes gateway status` 30 seconds later)
# because Windows routes console events to all processes sharing the
# console. Every hermes CLI process after that sibling fires is a
# potential drive-by killer. So on Windows, for `gateway run`
# specifically (never interactive by design), always install the
# SIGINT absorber regardless of TTY state.
# Detached Windows gateway runs must ignore console-control broadcasts
# from sibling CLI processes, but foreground `hermes gateway run` still
# needs to obey the banner's "Press Ctrl+C to stop" contract.
# Service-style launchers set HERMES_GATEWAY_DETACHED=1; older wrappers
# without the marker are handled by the non-TTY fallback.
try:
_stdin_is_tty = bool(sys.stdin and sys.stdin.isatty())
except (ValueError, OSError):
_stdin_is_tty = False
if is_windows():
_absorb_windows_console_controls = _windows_gateway_should_absorb_console_controls()
if _absorb_windows_console_controls:
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
if hasattr(signal, "SIGBREAK"):
@@ -3103,6 +3220,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
replace=replace,
argv=sys.argv,
stdin_is_tty=_stdin_is_tty,
absorb_windows_console_controls=_absorb_windows_console_controls,
)
def _atexit_hook() -> None:
+2
View File
@@ -216,6 +216,7 @@ def _build_gateway_cmd_script(
lines.append(f"cd /d {_quote_cmd_script_arg(working_dir)}")
lines.append(f'set "HERMES_HOME={hermes_home}"')
lines.append('set "PYTHONIOENCODING=utf-8"')
lines.append('set "HERMES_GATEWAY_DETACHED=1"')
# VIRTUAL_ENV lets the gateway's own python detection find the venv
# if someone imports hermes_constants-based logic during startup.
venv_dir = str(Path(python_path).resolve().parent.parent)
@@ -371,6 +372,7 @@ def _build_gateway_argv() -> tuple[list[str], str, dict[str, str]]:
env_overlay = {
"HERMES_HOME": hermes_home,
"PYTHONIOENCODING": "utf-8",
"HERMES_GATEWAY_DETACHED": "1",
"VIRTUAL_ENV": str(Path(python_exe).resolve().parent.parent),
}
return argv, working_dir, env_overlay
+58 -14
View File
@@ -2136,6 +2136,29 @@ def _cmd_gc(args: argparse.Namespace) -> int:
# Slash-command entry point (used by /kanban from CLI and gateway)
# ---------------------------------------------------------------------------
_SLASH_KANBAN_HELP = """\
**/kanban** manage the shared task board.
Common subcommands:
`list` (alias `ls`) List tasks on the current board
`show <id>` Task details + comments + events
`stats` Per-status / per-assignee counts
`create <title>` Create a task (auto-subscribes you to events)
`comment <id> <msg>` Append a comment
`complete <id>` Mark task(s) done
`block <id> [reason]` Mark blocked; `unblock <id>` to revive
`assign <id> <profile>` Reassign
`boards list` Show all boards
`assignees` Known profiles + counts
`context <id>` Full worker-context dump
`runs <id>` Attempt history
`log <id>` Worker log
Run `/kanban <subcommand> -h` for arguments. \
Read-only commands are safe while an agent is running.\
"""
def run_slash(rest: str) -> str:
"""Execute a ``/kanban …`` string and return captured stdout/stderr.
@@ -2148,26 +2171,47 @@ def run_slash(rest: str) -> str:
tokens = shlex.split(rest) if rest and rest.strip() else []
parser = argparse.ArgumentParser(prog="/kanban", add_help=False)
parser.exit_on_error = False # type: ignore[attr-defined]
sub = parser.add_subparsers(dest="kanban_action")
# Reuse the argparse builder -- call it with a throwaway parent
# subparsers via a wrapping top-level parser.
wrap = argparse.ArgumentParser(prog="/", add_help=False)
wrap.exit_on_error = False # type: ignore[attr-defined]
wrap_sub = wrap.add_subparsers(dest="_top")
build_parser(wrap_sub)
# Bare ``/kanban`` or ``/kanban help`` / ``--help`` / ``-h`` / ``?``:
# show the curated short-help block instead of dumping argparse's full
# usage tree (which is enormous and reads as garbage in a chat
# bubble). Per-subcommand help still works via ``/kanban foo -h``.
if not tokens or tokens[0] in {"help", "--help", "-h", "?"}:
return _SLASH_KANBAN_HELP
# Single argparse tree rooted at "/kanban". build_parser() expects a
# subparsers action to attach to, so build a throwaway one and pull
# the kanban_parser back out — then drive it directly so usage/error
# text reads as ``/kanban`` (not ``/kanban-wrap kanban``).
_wrap = argparse.ArgumentParser(prog="/kanban-wrap", add_help=False)
_wrap.exit_on_error = False # type: ignore[attr-defined]
_top_sub = _wrap.add_subparsers(dest="_top")
kanban_parser = build_parser(_top_sub)
kanban_parser.prog = "/kanban"
kanban_parser.exit_on_error = False # type: ignore[attr-defined]
for _action in kanban_parser._actions:
if isinstance(_action, argparse._SubParsersAction):
for _name, _choice in _action.choices.items():
_choice.prog = f"/kanban {_name}"
_choice.exit_on_error = False # type: ignore[attr-defined]
buf_out = io.StringIO()
buf_err = io.StringIO()
# ``-h`` / ``--help`` makes argparse print to stdout and SystemExit(0).
# Capture both streams so neither the help text nor the error text
# bypasses our buffer.
try:
# Prepend the "kanban" token so our top-level subparser routes here.
argv = ["kanban", *tokens] if tokens else ["kanban"]
args = wrap.parse_args(argv)
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
args = kanban_parser.parse_args(tokens)
except SystemExit as exc:
return f"(usage error: {exc})"
out = buf_out.getvalue().rstrip()
err = buf_err.getvalue().rstrip()
# Help dump (exit 0) → return the captured help text directly.
if exc.code in (0, None) and out:
return out
body = err or out
return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error"
except argparse.ArgumentError as exc:
return f"(usage error: {exc})"
return f"⚠ /kanban usage error: {exc}"
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
try:
+114 -24
View File
@@ -963,6 +963,25 @@ def init_db(
return path
def _add_column_if_missing(
conn: sqlite3.Connection, table: str, column: str, ddl: str
) -> bool:
"""Run ``ALTER TABLE <table> ADD COLUMN <ddl>``, idempotent across races.
Returns ``True`` when the column was actually added by this call.
Swallows ``duplicate column name`` errors so a concurrent connection
that ran the same migration first does not crash the dispatcher tick
(issue #21708).
"""
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
return True
except sqlite3.OperationalError as exc:
if "duplicate column name" in str(exc).lower():
return False
raise
def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
"""Add columns that were introduced after v1 release to legacy DBs.
@@ -970,11 +989,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
"""
cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")}
if "tenant" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN tenant TEXT")
_add_column_if_missing(conn, "tasks", "tenant", "tenant TEXT")
if "result" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN result TEXT")
_add_column_if_missing(conn, "tasks", "result", "result TEXT")
if "idempotency_key" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN idempotency_key TEXT")
_add_column_if_missing(
conn, "tasks", "idempotency_key", "idempotency_key TEXT"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_idempotency "
"ON tasks(idempotency_key)"
@@ -997,37 +1018,51 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
# the *original* snapshot; this is intentional and safe as long as
# no step depends on a column added by a previous step in the same call.
if "consecutive_failures" not in cols:
conn.execute(
"ALTER TABLE tasks ADD COLUMN consecutive_failures "
"INTEGER NOT NULL DEFAULT 0"
added = _add_column_if_missing(
conn,
"tasks",
"consecutive_failures",
"consecutive_failures INTEGER NOT NULL DEFAULT 0",
)
if "spawn_failures" in cols:
if added and "spawn_failures" in cols:
conn.execute(
"UPDATE tasks SET consecutive_failures = COALESCE(spawn_failures, 0)"
)
if "worker_pid" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN worker_pid INTEGER")
_add_column_if_missing(conn, "tasks", "worker_pid", "worker_pid INTEGER")
if "last_failure_error" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN last_failure_error TEXT")
if "last_spawn_error" in cols:
added = _add_column_if_missing(
conn, "tasks", "last_failure_error", "last_failure_error TEXT"
)
if added and "last_spawn_error" in cols:
conn.execute(
"UPDATE tasks SET last_failure_error = last_spawn_error"
)
if "max_runtime_seconds" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN max_runtime_seconds INTEGER")
_add_column_if_missing(
conn, "tasks", "max_runtime_seconds", "max_runtime_seconds INTEGER"
)
if "last_heartbeat_at" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN last_heartbeat_at INTEGER")
_add_column_if_missing(
conn, "tasks", "last_heartbeat_at", "last_heartbeat_at INTEGER"
)
if "current_run_id" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN current_run_id INTEGER")
_add_column_if_missing(
conn, "tasks", "current_run_id", "current_run_id INTEGER"
)
if "workflow_template_id" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN workflow_template_id TEXT")
_add_column_if_missing(
conn, "tasks", "workflow_template_id", "workflow_template_id TEXT"
)
if "current_step_key" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN current_step_key TEXT")
_add_column_if_missing(
conn, "tasks", "current_step_key", "current_step_key TEXT"
)
if "skills" not in cols:
# JSON array of skill names the dispatcher force-loads into the
# worker (additive to the built-in `kanban-worker`). NULL is fine
# for existing rows.
conn.execute("ALTER TABLE tasks ADD COLUMN skills TEXT")
_add_column_if_missing(conn, "tasks", "skills", "skills TEXT")
if "max_retries" not in cols:
# Per-task override for the consecutive-failure circuit breaker.
@@ -1035,13 +1070,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
# config, then ``DEFAULT_FAILURE_LIMIT``. Existing rows get NULL,
# which is the correct default (they keep the global behaviour
# they were getting before the column existed).
conn.execute("ALTER TABLE tasks ADD COLUMN max_retries INTEGER")
_add_column_if_missing(conn, "tasks", "max_retries", "max_retries INTEGER")
# task_events gained a run_id column; back-fill it as NULL for
# historical events (they predate runs and can't be attributed).
ev_cols = {row["name"] for row in conn.execute("PRAGMA table_info(task_events)")}
if "run_id" not in ev_cols:
conn.execute("ALTER TABLE task_events ADD COLUMN run_id INTEGER")
_add_column_if_missing(conn, "task_events", "run_id", "run_id INTEGER")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_events_run "
"ON task_events(run_id, id)"
@@ -1504,7 +1539,14 @@ def unlink_tasks(conn: sqlite3.Connection, parent_id: str, child_id: str) -> boo
conn, child_id, "unlinked",
{"parent": parent_id, "child": child_id},
)
return cur.rowcount > 0
removed = cur.rowcount > 0
if removed:
# Dependency edge removed — re-evaluate promotion eligibility for the
# child immediately. Matches the contract of complete_task and
# unblock_task; without this the child stays stuck in todo until the
# next dispatcher tick or a manual `hermes kanban recompute` (issue #22459).
recompute_ready(conn)
return removed
def parent_ids(conn: sqlite3.Connection, task_id: str) -> list[str]:
@@ -1797,6 +1839,31 @@ def claim_task(
lock = claimer or _claimer_id()
expires = now + int(ttl_seconds)
with write_txn(conn):
# Structural invariant: never transition ready -> running while any
# parent is not yet 'done'. This is the single enforcement point
# regardless of which writer (create_task, link_tasks, unblock_task,
# release_stale_claims, manual SQL) set status='ready'. If a racy
# writer promoted a task with undone parents, demote it back to
# 'todo' here — recompute_ready will re-promote when the parents
# actually finish. See RCA at
# kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
undone = conn.execute(
"SELECT 1 FROM task_links l "
"JOIN tasks p ON p.id = l.parent_id "
"WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
(task_id,),
).fetchone()
if undone:
conn.execute(
"UPDATE tasks SET status = 'todo' "
"WHERE id = ? AND status = 'ready'",
(task_id,),
)
_append_event(
conn, task_id, "claim_rejected",
{"reason": "parents_not_done"},
)
return None
# Defensive: if a prior run somehow leaked (invariant violation from
# an unknown code path), close it as 'reclaimed' so we don't strand
# it when the CAS resets the pointer below. No-op when the invariant
@@ -2496,14 +2563,30 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
""",
(now, int(stale["current_run_id"])),
)
cur = conn.execute(
"UPDATE tasks SET status = 'ready', current_run_id = NULL "
"WHERE id = ? AND status = 'blocked'",
# Re-gate on parent completion before flipping 'blocked' back to
# 'ready'. Unconditionally setting status='ready' here bypasses the
# parent-completion invariant (the dispatcher trusts that column);
# if parents are still in progress the task must wait in 'todo'
# until recompute_ready picks it up. RCA: Bug 2 at
# kanban/boards/cookai/workspaces/t_a6acd07d/root-cause.md.
undone_parents = conn.execute(
"SELECT 1 FROM task_links l "
"JOIN tasks p ON p.id = l.parent_id "
"WHERE l.child_id = ? AND p.status != 'done' LIMIT 1",
(task_id,),
).fetchone()
new_status = "todo" if undone_parents else "ready"
cur = conn.execute(
"UPDATE tasks SET status = ?, current_run_id = NULL "
"WHERE id = ? AND status = 'blocked'",
(new_status, task_id),
)
if cur.rowcount != 1:
return False
_append_event(conn, task_id, "unblocked", None)
_append_event(
conn, task_id, "unblocked",
{"status": new_status} if new_status != "ready" else None,
)
return True
@@ -4024,7 +4107,14 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
)
for c in shown_c:
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(c.created_at))
lines.append(f"**{c.author}** ({ts}):")
# Render author with explicit "comment from worker" framing so
# operator-controlled HERMES_PROFILE values like "hermes-system"
# or "operator" can't be misread by the next worker as a system
# directive above the (attacker-influenceable) comment body.
# Defense-in-depth — the LLM-controlled author-forgery surface
# was already closed in #22435. See #22452.
safe_author = (c.author or "").replace("`", "")
lines.append(f"comment from worker `{safe_author}` at {ts}:")
lines.append(_cap(c.body, _CTX_MAX_COMMENT_BYTES))
lines.append("")
+247 -31
View File
@@ -144,11 +144,19 @@ def _apply_profile_override() -> None:
profile_name = None
consume = 0
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
# This lets child processes (relaunch, subprocess) inherit the parent's
# profile choice without having to pass --profile again.
if profile_name is None and os.environ.get("HERMES_HOME"):
return
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it
# only when it already points to a specific profile directory. The
# distinguishing heuristic: a profile path has "profiles" as its immediate
# parent directory name (e.g. ~/.hermes/profiles/coder or
# /opt/data/profiles/coder). If HERMES_HOME points to the hermes root
# instead (e.g. systemd hardcodes HERMES_HOME=/root/.hermes), we must
# still read active_profile — the user may have switched profiles via
# `hermes profile use` and the gateway should honour that choice.
# See issue #22502.
hermes_home_env = os.environ.get("HERMES_HOME", "")
if profile_name is None and hermes_home_env:
if Path(hermes_home_env).parent.name == "profiles":
return
# 2. If no flag, check active_profile in the hermes root
if profile_name is None:
@@ -5738,6 +5746,92 @@ def _print_curator_first_run_notice() -> None:
)
def _print_curator_recent_run_notice() -> None:
"""Print the most recent curator run summary, exactly once.
The curator runs in the background (gateway tick + CLI session start),
so users learn about skill consolidations only by stumbling into a
rename. ``hermes update`` is a high-attention surface surface the
most recent run's rename map here, once.
Show-once: state stamps ``last_run_summary_shown_at`` after printing.
Subsequent ``hermes update`` invocations skip the block until a newer
curator run lands. Silent when the curator has never run, when the
most recent summary has already been shown, or when the summary has
no rename information to display (no archives).
"""
try:
from agent import curator
except Exception:
return
try:
state = curator.load_state()
except Exception:
return
last_run_at = state.get("last_run_at")
if not last_run_at:
return # no curator run yet — first-run notice handles this case
if state.get("last_run_summary_shown_at") == last_run_at:
return # already shown for this run
summary = state.get("last_run_summary") or ""
if not summary:
return
# Only print when there's something interesting to show — i.e. the
# rename map block was appended (multi-line summary). A bare "auto:
# no changes; llm: no change" doesn't warrant interrupting the
# update flow.
if "\n" not in summary:
# Still stamp it shown so we don't reconsider it on every update.
try:
state["last_run_summary_shown_at"] = last_run_at
curator.save_state(state)
except Exception:
pass
return
# Format the timestamp as "Xh ago" for readability.
when = _format_time_ago(last_run_at)
print()
print(f" Skill curator — last run {when}")
for line in summary.splitlines():
print(f" {line}")
print(
" (This message shows once per curator run. "
"View anytime: hermes curator status)"
)
# Stamp shown so we don't repeat on the next update.
try:
state["last_run_summary_shown_at"] = last_run_at
curator.save_state(state)
except Exception:
pass
def _format_time_ago(iso_ts: str) -> str:
"""Render an ISO timestamp as `Xh ago` / `Xd ago` / `Xm ago`. Best effort."""
try:
from datetime import datetime, timezone
ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - ts
secs = int(delta.total_seconds())
if secs < 60:
return "just now"
if secs < 3600:
return f"{secs // 60}m ago"
if secs < 86400:
return f"{secs // 3600}h ago"
return f"{secs // 86400}d ago"
except Exception:
return "recently"
def _kill_stale_dashboard_processes(
reason: str = "the running backend no longer matches the updated frontend",
) -> None:
@@ -5983,6 +6077,10 @@ def _update_via_zip(args):
_print_curator_first_run_notice()
except Exception as e:
logger.debug("Curator first-run notice failed: %s", e)
try:
_print_curator_recent_run_notice()
except Exception as e:
logger.debug("Curator recent-run notice failed: %s", e)
_kill_stale_dashboard_processes()
@@ -6439,13 +6537,11 @@ def _invalidate_update_cache():
pass
def _load_installable_optional_extras() -> list[str]:
"""Return the optional extras referenced by the ``all`` group.
def _load_installable_optional_extras(group: str = "all") -> list[str]:
"""Return optional extras referenced by a dependency group.
Only extras that ``[all]`` actually pulls in are retried individually.
Extras outside ``[all]`` (e.g. ``rl``, ``yc-bench``) are intentionally
excluded they have heavy or platform-specific deps that most users
never installed.
``group`` is usually ``all`` (desktop/server broad install) or
``termux-all`` (Termux-compatible broad install).
"""
try:
import tomllib
@@ -6459,11 +6555,9 @@ def _load_installable_optional_extras() -> list[str]:
if not isinstance(optional_deps, dict):
return []
# Parse the [all] group to find which extras it references.
# Entries look like "hermes-agent[matrix]" or "package-name[extra]".
all_refs = optional_deps.get("all", [])
refs = optional_deps.get(group, [])
referenced: list[str] = []
for ref in all_refs:
for ref in refs:
if "[" in ref and "]" in ref:
name = ref.split("[", 1)[1].split("]", 1)[0]
if name in optional_deps:
@@ -6515,25 +6609,16 @@ def _install_python_dependencies_with_optional_fallback(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
group: str = "all",
) -> None:
"""Install base deps plus as many optional extras as the environment supports.
We intentionally do NOT pass ``--quiet`` to pip. On platforms without
prebuilt wheels for some extras (Termux/Android aarch64, older musl
distros, fresh Raspberry Pi) pip has to compile C/Rust extensions from
source, which can take several minutes with zero network activity.
Without progress output the call looks like a hang and users Ctrl+C it.
Pip's default output is proportional to actual work (one line per
Collecting/Building/Installing step), so keeping it visible costs
nothing on fast hardware and prevents the "hermes update hangs" reports
on slow hardware.
We also add periodic heartbeat lines in case the resolver/build backend is
itself silent for long stretches.
By default this targets ``.[all]``; Termux callers can pass
``group='termux-all'`` to use the curated Android-compatible profile.
"""
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", ".[all]"],
install_cmd_prefix + ["install", "-e", f".[{group}]"],
env=env,
)
return
@@ -6549,7 +6634,7 @@ def _install_python_dependencies_with_optional_fallback(
failed_extras: list[str] = []
installed_extras: list[str] = []
for extra in _load_installable_optional_extras():
for extra in _load_installable_optional_extras(group=group):
try:
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
@@ -6575,6 +6660,65 @@ def _is_termux_env(env: dict[str, str] | None = None) -> bool:
return "com.termux" in prefix or prefix.startswith("/data/data/com.termux/")
def _is_android_python() -> bool:
return sys.platform == "android"
def _install_psutil_android_compat(
install_cmd_prefix: list[str],
*,
env: dict[str, str] | None = None,
) -> None:
"""Install psutil on Android by patching upstream platform detection.
psutil's setup currently gates Linux sources behind
``sys.platform.startswith('linux')``. On Termux Python reports
``sys.platform == 'android'``, so setup aborts with
"platform android is not supported" despite compiling fine when using the
Linux source path.
We patch only the extracted build tree used for this install attempt;
nothing is persisted in the repository.
Stopgap: remove this once https://github.com/giampaolo/psutil/pull/2762
merges and ships in a release. ``scripts/install_psutil_android.py``
contains the same logic for ``scripts/install.sh`` (fresh installs).
Both copies should be removed together.
"""
import tarfile
import tempfile
import urllib.request
psutil_url = (
"https://files.pythonhosted.org/packages/aa/c6/"
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
"psutil-7.2.2.tar.gz"
)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(psutil_url, archive)
with tarfile.open(archive) as tar:
tar.extractall(tmp_path)
src_root = next(
p for p in tmp_path.iterdir() if p.is_dir() and p.name.startswith("psutil-")
)
common_py = src_root / "psutil" / "_common.py"
content = common_py.read_text(encoding="utf-8")
marker = 'LINUX = sys.platform.startswith("linux")'
replacement = 'LINUX = sys.platform.startswith(("linux", "android"))'
if marker not in content:
raise RuntimeError("psutil Android compatibility patch marker not found")
common_py.write_text(content.replace(marker, replacement), encoding="utf-8")
_run_install_with_heartbeat(
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)],
env=env,
)
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
@@ -7334,13 +7478,20 @@ def _cmd_update_impl(args, gateway_mode: bool):
print("→ Updating Python dependencies...")
pip_cmd = [sys.executable, "-m", "pip"]
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
install_group = "termux-all"
print(" → Termux detected: using uv + curated termux-all optional profile...")
if _is_termux_env(uv_env) and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"], env=uv_env
[uv_bin, "pip"], env=uv_env, group=install_group
)
else:
# Use sys.executable to explicitly call the venv's pip module,
@@ -7361,7 +7512,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
cwd=PROJECT_ROOT,
check=True,
)
_install_python_dependencies_with_optional_fallback(pip_cmd)
if _is_termux_env():
install_group = "termux-all"
print(" → Termux detected: using curated termux-all optional profile...")
if _is_termux_env() and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
_update_node_dependencies()
_build_web_ui(PROJECT_ROOT / "apps" / "dashboard")
@@ -7541,6 +7698,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("Curator first-run notice failed: %s", e)
# Most-recent curator run notice — show-once per run. Surfaces the
# rename map (`old-name → umbrella`) on the high-attention update
# surface so users learn about consolidations without having to
# check `hermes curator status`. Self-stamps after printing so it
# never repeats for the same run.
try:
_print_curator_recent_run_notice()
except Exception as e:
logger.debug("Curator recent-run notice failed: %s", e)
# Repair RHEL-family root installs where /usr/local/bin isn't on PATH
# for non-login interactive shells. No-op on every other platform.
try:
@@ -8884,6 +9051,7 @@ def _build_provider_choices() -> list[str]:
_BUILTIN_SUBCOMMANDS = frozenset(
{
"acp", "auth", "backup", "checkpoints", "claw", "completion",
"computer-use",
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
"kanban", "login", "logout", "logs", "mcp", "memory", "model",
@@ -10504,6 +10672,54 @@ Examples:
tools_command(args)
tools_parser.set_defaults(func=cmd_tools)
# =========================================================================
# computer-use command — manage Computer Use (cua-driver) on macOS
# =========================================================================
computer_use_parser = subparsers.add_parser(
"computer-use",
help="Manage the Computer Use (cua-driver) backend (macOS)",
description=(
"Install or check the cua-driver binary used by the\n"
"`computer_use` toolset. macOS-only.\n\n"
"Use `hermes computer-use install` to fetch and run the\n"
"upstream cua-driver installer. This is equivalent to the\n"
"post-setup hook that `hermes tools` runs when you first\n"
"enable the Computer Use toolset, and is a stable target\n"
"for re-running the install if it didn't fire (e.g. when\n"
"toggling the toolset on a returning-user setup)."
),
)
computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action")
computer_use_sub.add_parser(
"install",
help="Install or repair the cua-driver binary (macOS)",
)
computer_use_sub.add_parser(
"status",
help="Print whether cua-driver is installed and on PATH",
)
def cmd_computer_use(args):
action = getattr(args, "computer_use_action", None)
if action == "install":
from hermes_cli.tools_config import _run_post_setup
_run_post_setup("cua_driver")
return
if action == "status":
import shutil
path = shutil.which("cua-driver")
if path:
print(f"cua-driver: installed at {path}")
return
print("cua-driver: not installed")
print(" Run: hermes computer-use install")
return
# No subcommand → show help
computer_use_parser.print_help()
computer_use_parser.set_defaults(func=cmd_computer_use)
# =========================================================================
# mcp command — manage MCP server connections
# =========================================================================
+6 -1
View File
@@ -31,7 +31,12 @@ logger = logging.getLogger(__name__)
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_MCP_PRESETS: Dict[str, Dict[str, Any]] = {}
_MCP_PRESETS: Dict[str, Dict[str, Any]] = {
"codex": {
"command": "codex",
"args": ["mcp-server"],
},
}
# ─── UI Helpers ───────────────────────────────────────────────────────────────
+11 -5
View File
@@ -889,10 +889,9 @@ def switch_model(
# "ollama-launch" that resolve_runtime_provider doesn't know), keep existing
# credentials. Otherwise use the resolved values (picks up credential rotation,
# base_url adjustments for OpenCode, etc.).
if runtime.get("provider") != "custom":
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
api_key = runtime.get("api_key", "")
base_url = runtime.get("base_url", "")
api_mode = runtime.get("api_mode", "")
except Exception:
pass
@@ -1343,7 +1342,14 @@ def list_authenticated_providers(
if not has_creds:
continue
if hermes_slug in {"copilot", "copilot-acp"}:
if hermes_slug in {"openai-codex", "copilot", "copilot-acp"}:
# Use live OAuth-backed discovery so the gateway /model picker
# matches what the user's authenticated Codex/Copilot backend
# actually serves — including ChatGPT-Pro-only Codex slugs
# (e.g. gpt-5.3-codex-spark) that aren't in the static curated
# catalog. ``provider_model_ids()`` falls back to the curated
# list when the live endpoint is unreachable, so this is safe
# for unauthenticated and offline cases too.
model_ids = provider_model_ids(hermes_slug)
# For aws_sdk providers (bedrock), use live discovery so the list
# reflects the active region (eu.*, ap.*) not the static us.* list.
+36 -50
View File
@@ -32,44 +32,38 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
("moonshotai/kimi-k2.6", "recommended"),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("qwen/qwen3.6-plus", ""),
("anthropic/claude-sonnet-4.5", ""),
("anthropic/claude-haiku-4.5", ""),
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("openai/gpt-5.5", ""),
("openai/gpt-5.4-mini", ""),
("xiaomi/mimo-v2.5-pro", ""),
("xiaomi/mimo-v2.5", ""),
("tencent/hy3-preview:free", "free"),
("tencent/hy3-preview", ""),
("openai/gpt-5.3-codex", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("anthropic/claude-opus-4.7", ""),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("moonshotai/kimi-k2.6", "recommended"),
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
("qwen/qwen3.6-plus", ""),
("anthropic/claude-haiku-4.5", ""),
("openai/gpt-5.5", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-mini", ""),
("openai/gpt-5.4-nano", ""),
("openai/gpt-5.3-codex", ""),
("xiaomi/mimo-v2.5-pro", ""),
("tencent/hy3-preview", ""),
("google/gemini-3-pro-image-preview", ""),
("google/gemini-3-flash-preview", ""),
("google/gemini-3.1-pro-preview", ""),
("google/gemini-3.1-flash-lite-preview", ""),
("qwen/qwen3.5-plus-02-15", ""),
("qwen/qwen3.5-35b-a3b", ""),
("stepfun/step-3.5-flash", ""),
("minimax/minimax-m2.7", ""),
("minimax/minimax-m2.5", ""),
("minimax/minimax-m2.5:free", "free"),
("z-ai/glm-5.1", ""),
("z-ai/glm-5v-turbo", ""),
("z-ai/glm-5-turbo", ""),
("x-ai/grok-4.20", ""),
("x-ai/grok-4.3", ""),
("qwen/qwen3.6-35b-a3b", ""),
("stepfun/step-3.5-flash", ""),
("minimax/minimax-m2.7", ""),
("z-ai/glm-5.1", ""),
("x-ai/grok-4.20", ""),
("x-ai/grok-4.3", ""),
("nvidia/nemotron-3-super-120b-a12b", ""),
("deepseek/deepseek-v4-pro", ""),
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("tencent/hy3-preview:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("arcee-ai/trinity-large-preview:free", "free"),
("arcee-ai/trinity-large-thinking", ""),
("openai/gpt-5.5-pro", ""),
("openai/gpt-5.4-nano", ""),
("deepseek/deepseek-v4-pro", ""),
("inclusionai/ring-2.6-1t:free", "free"),
]
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
@@ -158,37 +152,29 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"moonshotai/kimi-k2.6",
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"tencent/hy3-preview",
"anthropic/claude-opus-4.7",
"anthropic/claude-opus-4.6",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-4.5",
"moonshotai/kimi-k2.6",
"qwen/qwen3.6-plus",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.5",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-mini",
"openai/gpt-5.4-nano",
"openai/gpt-5.3-codex",
"xiaomi/mimo-v2.5-pro",
"tencent/hy3-preview",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
"google/gemini-3.1-pro-preview",
"google/gemini-3.1-flash-lite-preview",
"qwen/qwen3.5-plus-02-15",
"qwen/qwen3.5-35b-a3b",
"qwen/qwen3.6-35b-a3b",
"stepfun/step-3.5-flash",
"minimax/minimax-m2.7",
"minimax/minimax-m2.5",
"minimax/minimax-m2.5:free",
"z-ai/glm-5.1",
"z-ai/glm-5v-turbo",
"z-ai/glm-5-turbo",
"x-ai/grok-4.20-beta",
"x-ai/grok-4.3",
"nvidia/nemotron-3-super-120b-a12b",
"arcee-ai/trinity-large-thinking",
"openai/gpt-5.5-pro",
"openai/gpt-5.4-nano",
"deepseek/deepseek-v4-pro",
],
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
+19
View File
@@ -199,6 +199,22 @@ def run_oneshot(
return 0
def _create_session_db_for_oneshot():
"""Best-effort SessionDB for ``hermes -z`` / oneshot mode.
Oneshot bypasses ``HermesCLI._init_agent()``, so it must wire the SQLite
session store itself. Without this, the ``session_search``/recall tool is
advertised but every call returns "Session database not available.".
"""
try:
from hermes_state import SessionDB
return SessionDB()
except Exception as exc:
logging.debug("SQLite session store not available for oneshot mode: %s", exc)
return None
def _run_agent(
prompt: str,
model: Optional[str] = None,
@@ -284,6 +300,8 @@ def _run_agent(
if toolsets_list is None and use_config_toolsets:
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
session_db = _create_session_db_for_oneshot()
agent = AIAgent(
api_key=runtime.get("api_key"),
base_url=runtime.get("base_url"),
@@ -293,6 +311,7 @@ def _run_agent(
enabled_toolsets=toolsets_list,
quiet_mode=True,
platform="cli",
session_db=session_db,
credential_pool=runtime.get("credential_pool"),
# Interactive callbacks are intentionally NOT wired beyond this
# one. In oneshot mode there's no user sitting at a terminal:
+101 -13
View File
@@ -71,6 +71,56 @@ except ImportError: # pragma: no cover yaml is optional at import time
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Plugin developer debug logging
# ---------------------------------------------------------------------------
#
# Set ``HERMES_PLUGINS_DEBUG=1`` to surface verbose plugin-discovery logs to
# stderr in addition to ~/.hermes/logs/agent.log. Aimed at plugin authors
# trying to figure out why their plugin isn't showing up: which directories
# were scanned, which manifests parsed, which plugins were skipped (and why),
# what each ``register(ctx)`` call registered, and full tracebacks on load
# failure.
#
# The env var is read once at import time; tests that need to flip it
# mid-process can call ``_install_plugin_debug_handler(force=True)``.
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in (
"1", "true", "yes", "on",
)
_DEBUG_HANDLER_INSTALLED = False
def _install_plugin_debug_handler(force: bool = False) -> None:
"""When HERMES_PLUGINS_DEBUG is on, tee plugin logs to stderr at DEBUG.
Idempotent: only attaches the handler once per process unless ``force``
is passed. Does not touch the root logger or other Hermes loggers.
"""
global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG
if force:
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in (
"1", "true", "yes", "on",
)
if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED:
return
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
# Don't double-emit through the root logger when the central logging
# config also writes to stderr. agent.log still captures everything.
logger.propagate = True
_DEBUG_HANDLER_INSTALLED = True
logger.debug(
"HERMES_PLUGINS_DEBUG=1 — verbose plugin discovery logging enabled"
)
_install_plugin_debug_handler()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -653,28 +703,43 @@ class PluginManager:
# is a category holding platform adapters (scanned one level deeper
# below).
repo_plugins = get_bundled_plugins_dir()
manifests.extend(
self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
logger.debug("Scanning bundled plugins: %s", repo_plugins)
bundled = self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
manifests.extend(
self._scan_directory(repo_plugins / "platforms", source="bundled")
logger.debug(" bundled (top-level): %d manifest(s)", len(bundled))
manifests.extend(bundled)
bundled_platforms = self._scan_directory(
repo_plugins / "platforms", source="bundled"
)
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
manifests.extend(self._scan_directory(user_dir, source="user"))
logger.debug("Scanning user plugins: %s", user_dir)
user_manifests = self._scan_directory(user_dir, source="user")
logger.debug(" user: %d manifest(s)", len(user_manifests))
manifests.extend(user_manifests)
# 3. Project plugins (./.hermes/plugins/)
if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
project_dir = Path.cwd() / ".hermes" / "plugins"
manifests.extend(self._scan_directory(project_dir, source="project"))
logger.debug("Scanning project plugins: %s", project_dir)
project_manifests = self._scan_directory(project_dir, source="project")
logger.debug(" project: %d manifest(s)", len(project_manifests))
manifests.extend(project_manifests)
else:
logger.debug(
"Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)"
)
# 4. Pip / entry-point plugins
manifests.extend(self._scan_entry_points())
ep_manifests = self._scan_entry_points()
logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests))
manifests.extend(ep_manifests)
# Load each manifest (skip user-disabled plugins).
# Later sources override earlier ones on key collision — user
@@ -923,6 +988,10 @@ class PluginManager:
except Exception:
pass
logger.debug(
"Parsed manifest: key=%s name=%s kind=%s source=%s path=%s",
key, name, kind, source, plugin_dir,
)
return PluginManifest(
name=name,
version=str(data.get("version", "")),
@@ -937,7 +1006,9 @@ class PluginManager:
key=key,
)
except Exception as exc:
logger.warning("Failed to parse %s: %s", manifest_file, exc)
logger.warning(
"Failed to parse %s: %s", manifest_file, exc, exc_info=_PLUGINS_DEBUG,
)
return None
# -----------------------------------------------------------------------
@@ -977,6 +1048,10 @@ class PluginManager:
def _load_plugin(self, manifest: PluginManifest) -> None:
"""Import a plugin module and call its ``register(ctx)`` function."""
loaded = LoadedPlugin(manifest=manifest)
logger.debug(
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
)
try:
if manifest.source in ("user", "project", "bundled"):
@@ -1019,10 +1094,23 @@ class PluginManager:
if self._plugin_commands[c].get("plugin") == manifest.name
]
loaded.enabled = True
logger.debug(
" registered: %d tool(s), %d hook(s), %d slash command(s), %d CLI command(s)",
len(loaded.tools_registered),
len(loaded.hooks_registered),
len(loaded.commands_registered),
sum(
1 for c in self._cli_commands
if self._cli_commands[c].get("plugin") == manifest.name
),
)
except Exception as exc:
loaded.error = str(exc)
logger.warning("Failed to load plugin '%s': %s", manifest.name, exc)
logger.warning(
"Failed to load plugin '%s': %s",
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
)
self._plugins[manifest.key or manifest.name] = loaded
+45 -2
View File
@@ -9,6 +9,7 @@ rendered with Rich Markdown. Otherwise a default confirmation is shown.
from __future__ import annotations
import functools
import logging
import os
import shutil
@@ -23,6 +24,41 @@ from hermes_cli.config import cfg_get
logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=1)
def _resolve_git_executable() -> Optional[str]:
"""Resolve a git binary for subprocess use when ``PATH`` may be minimal.
Matches other Hermes subprocess resolution: :func:`shutil.which` first,
then common Git for Windows install paths and POSIX defaults.
"""
found = shutil.which("git")
if found:
return found
if os.name == "nt":
prog = os.environ.get("ProgramFiles", r"C:\Program Files")
prog_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
candidates = [
os.path.join(prog, "Git", "cmd", "git.exe"),
os.path.join(prog, "Git", "bin", "git.exe"),
os.path.join(prog_x86, "Git", "cmd", "git.exe"),
os.path.join(prog_x86, "Git", "bin", "git.exe"),
]
if local:
candidates.extend(
(
os.path.join(local, "Programs", "Git", "cmd", "git.exe"),
os.path.join(local, "Programs", "Git", "bin", "git.exe"),
)
)
else:
candidates = ["/usr/bin/git", "/usr/local/bin/git", "/bin/git"]
for c in candidates:
if c and os.path.isfile(c):
return c
return None
class PluginOperationError(Exception):
"""Recoverable plugin install/update failure (CLI exits; HTTP maps to 4xx)."""
@@ -324,9 +360,13 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
with tempfile.TemporaryDirectory() as tmp:
tmp_target = Path(tmp) / "plugin"
git_exe = _resolve_git_executable()
if not git_exe:
raise PluginOperationError("git is not installed or not in PATH.")
try:
result = subprocess.run(
["git", "clone", "--depth", "1", git_url, str(tmp_target)],
[git_exe, "clone", "--depth", "1", git_url, str(tmp_target)],
capture_output=True,
text=True,
timeout=60,
@@ -1472,9 +1512,12 @@ def dashboard_update_user_plugin(name: str) -> dict[str, Any]:
def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
git_exe = _resolve_git_executable()
if not git_exe:
return False, "git is not installed or not in PATH."
try:
result = subprocess.run(
["git", "pull", "--ff-only"],
[git_exe, "pull", "--ff-only"],
capture_output=True,
text=True,
timeout=60,
+32
View File
@@ -49,3 +49,35 @@ def install_shift_enter_alias() -> int:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
return changed
def install_ctrl_enter_alias() -> int:
"""Map Ctrl+Enter byte sequences to the (Escape, ControlM) key tuple
that Alt+Enter produces, so the existing Alt+Enter newline handler
fires for terminals that emit a distinct Ctrl+Enter.
Sequences mapped:
- "\\x1b[13;5u" Kitty keyboard protocol / CSI-u, modifier=5 (Ctrl)
- "\\x1b[27;5;13~" xterm modifyOtherKeys=2, modifier=5 (Ctrl)
- "\\x1b[27;5;13u" alternate ordering some emitters use
Stock prompt_toolkit doesn't map any of these. Without this alias,
Kitty/mintty/xterm-with-modifyOtherKeys users over SSH never get a
Ctrl+Enter newline the keystroke arrives as a raw CSI sequence that
falls through to the default character-insert handler. See #22379.
Returns the number of sequences whose mapping was changed.
"""
try:
from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES
from prompt_toolkit.keys import Keys
except Exception:
return 0
alt_enter = (Keys.Escape, Keys.ControlM)
changed = 0
for seq in ("\x1b[13;5u", "\x1b[27;5;13~", "\x1b[27;5;13u"):
if ANSI_SEQUENCES.get(seq) != alt_enter:
ANSI_SEQUENCES[seq] = alt_enter
changed += 1
return changed
+7
View File
@@ -492,6 +492,13 @@ def _resolve_named_custom_runtime(
requested_norm = (requested_provider or "").strip().lower()
if requested_norm == "custom" and explicit_base_url:
base_url = explicit_base_url.strip().rstrip("/")
# Check credential pool first — mirrors the named-custom-provider path
# so bare `provider: custom` with a configured custom_providers entry
# also gets its api_key from the pool instead of env var fallbacks.
pool_result = _try_resolve_from_custom_pool(base_url, "custom", None)
if pool_result:
pool_result["source"] = "direct-alias"
return pool_result
api_key_candidates = [
(explicit_api_key or "").strip(),
os.getenv("OPENAI_API_KEY", "").strip(),
+73
View File
@@ -12,6 +12,7 @@ the `platform_toolsets` key.
import json as _json
import logging
import os
import shutil
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set
@@ -972,6 +973,38 @@ def _get_platform_tools(
ts for ts in toolset_names
if ts in configurable_keys and _toolset_allowed_for_platform(ts, platform)
}
# Mixed config: composite toolset alongside configurables (e.g.
# ``[hermes-cli, spotify]`` after enabling Spotify via ``hermes
# tools``). Without expansion the composite name is silently dropped,
# leaving sessions with only the configurable opt-ins and no native
# tools. Mirror the else-branch's subset inference, but apply
# _DEFAULT_OFF_TOOLSETS only to the implicit expansion — anything the
# user explicitly listed (e.g. ``spotify``) must survive.
composite_tools = set()
for ts_name in toolset_names:
if ts_name in configurable_keys or ts_name in plugin_ts_keys:
continue
if ts_name not in TOOLSETS:
continue
composite_tools.update(resolve_toolset(ts_name))
if composite_tools:
expanded = set()
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
if not _toolset_allowed_for_platform(ts_key, platform):
continue
ts_tools = set(resolve_toolset(ts_key))
if ts_tools and ts_tools.issubset(composite_tools):
expanded.add(ts_key)
default_off = set(_DEFAULT_OFF_TOOLSETS)
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
default_off.remove(platform)
if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
default_off.remove("homeassistant")
expanded -= default_off
enabled_toolsets |= expanded
else:
# No explicit config — fall back to resolving composite toolset names
# (e.g. "hermes-cli") to individual tool names and reverse-mapping.
@@ -1392,12 +1425,52 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
return visible
_POST_SETUP_INSTALLED: dict = {
# post_setup_key -> predicate(): True when the install side-effect
# is already satisfied. Used by `_toolset_needs_configuration_prompt`
# to force the provider-setup flow when a no-key provider still needs
# a binary/dependency install (otherwise an already-configured user
# who toggles the toolset on via `hermes tools` gets a silent no-op
# because the gate sees "no env vars to ask about" and skips the
# provider-setup flow that would have run the post_setup hook).
#
# Only entries here are gated; other post_setup hooks (kittentts,
# piper, agent_browser, etc.) keep their existing behaviour. Add an
# entry when (a) the post_setup is the ONLY install side-effect for
# a no-key provider, and (b) an installed-state check is cheap and
# doesn't trigger a heavy import.
"cua_driver": lambda: bool(shutil.which("cua-driver")),
}
def _post_setup_already_installed(post_setup_key: str) -> bool:
"""Return True when the post_setup install side-effect is satisfied."""
predicate = _POST_SETUP_INSTALLED.get(post_setup_key)
if predicate is None:
# No install-state check registered → assume satisfied (don't
# change behaviour for hooks we haven't explicitly opted in).
return True
try:
return bool(predicate())
except Exception:
return True
def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
"""Return True when enabling this toolset should open provider setup."""
cat = TOOL_CATEGORIES.get(ts_key)
if not cat:
return not _toolset_has_keys(ts_key, config)
# If any visible provider has a registered post_setup install-state
# check that hasn't been satisfied (e.g. cua-driver binary not on
# PATH yet), force the configuration flow so `_configure_provider`
# invokes `_run_post_setup` and the install actually runs.
for provider in _visible_providers(cat, config):
post_setup = provider.get("post_setup")
if post_setup and not _post_setup_already_installed(post_setup):
return True
if ts_key == "tts":
tts_cfg = config.get("tts", {})
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg