Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
# =========================================================================
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user