Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.12.0"
|
||||
__release_date__ = "2026.4.30"
|
||||
__version__ = "0.13.0"
|
||||
__release_date__ = "2026.5.7"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -70,6 +70,9 @@ Examples:
|
||||
hermes logs --since 1h Lines from the last hour
|
||||
hermes debug share Upload debug report for support
|
||||
hermes update Update to latest version
|
||||
hermes dashboard Start web UI dashboard (port 9119)
|
||||
hermes dashboard --stop Stop running dashboard processes
|
||||
hermes dashboard --status List running dashboard processes
|
||||
|
||||
For more help on a command:
|
||||
hermes <command> --help
|
||||
|
||||
+497
-195
@@ -780,42 +780,121 @@ def _auth_file_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def _global_auth_file_path() -> Optional[Path]:
|
||||
"""Return the global-root auth.json when the process is in profile mode.
|
||||
|
||||
Returns ``None`` when the profile and global root resolve to the same
|
||||
directory (classic mode, or custom HERMES_HOME that is not a profile).
|
||||
Used by read-only fallback paths so providers authed at the root are
|
||||
visible to profile processes that haven't configured them locally.
|
||||
|
||||
See issue #18594 follow-up (credential_pool shadowing).
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
global_root = get_default_hermes_root()
|
||||
except Exception:
|
||||
return None
|
||||
profile_home = get_hermes_home()
|
||||
try:
|
||||
if profile_home.resolve(strict=False) == global_root.resolve(strict=False):
|
||||
return None
|
||||
except Exception:
|
||||
if profile_home == global_root:
|
||||
return None
|
||||
# No pytest seat belt here: this is a pure read-only path, and
|
||||
# ``_load_global_auth_store()`` wraps the read in a try/except so an
|
||||
# unreadable global file can never break the profile process. The
|
||||
# write-side seat belt still lives on ``_auth_file_path()`` where it
|
||||
# belongs (that's what protects the real user's auth store from being
|
||||
# corrupted by a mis-configured test).
|
||||
return global_root / "auth.json"
|
||||
|
||||
|
||||
def _load_global_auth_store() -> Dict[str, Any]:
|
||||
"""Load the global-root auth store (read-only fallback).
|
||||
|
||||
Returns an empty dict when no global fallback exists (classic mode,
|
||||
or the global auth.json is absent). Never raises on missing file.
|
||||
|
||||
Seat belt: under pytest, refuses to read the real user's
|
||||
``~/.hermes/auth.json`` even when HERMES_HOME is set to a profile
|
||||
path. The hermetic conftest does not redirect ``HOME``, so
|
||||
``get_default_hermes_root()`` for a profile-shaped HERMES_HOME can
|
||||
still resolve to the real user's home on a dev machine. That would
|
||||
leak real credentials into tests. This guard uses the unmodified
|
||||
``HOME`` env var (what ``os.path.expanduser('~')`` would resolve to),
|
||||
not ``Path.home()``, because ``Path.home`` is sometimes monkeypatched
|
||||
by fixtures that want to relocate the global root to a tmp path.
|
||||
"""
|
||||
global_path = _global_auth_file_path()
|
||||
if global_path is None or not global_path.exists():
|
||||
return {}
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return {}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return _load_auth_store(global_path)
|
||||
except Exception:
|
||||
# A malformed global store must not break profile reads. The
|
||||
# profile's own auth store is still authoritative.
|
||||
return {}
|
||||
|
||||
|
||||
def _auth_lock_path() -> Path:
|
||||
return _auth_file_path().with_suffix(".lock")
|
||||
|
||||
|
||||
_auth_lock_holder = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-process advisory lock for auth.json reads+writes. Reentrant."""
|
||||
# Reentrant: if this thread already holds the lock, just yield.
|
||||
if getattr(_auth_lock_holder, "depth", 0) > 0:
|
||||
_auth_lock_holder.depth += 1
|
||||
def _file_lock(
|
||||
lock_path: Path,
|
||||
holder: threading.local,
|
||||
timeout_seconds: float,
|
||||
timeout_message: str,
|
||||
):
|
||||
"""Cross-process advisory flock helper.
|
||||
|
||||
Reentrant per-thread via ``holder.depth``. Falls back to a depth-only
|
||||
guard when neither ``fcntl`` nor ``msvcrt`` is available (rare).
|
||||
Callers supply their own ``threading.local`` so independent locks
|
||||
(e.g. profile auth.json vs shared Nous store) don't share reentrancy
|
||||
state — that would let one lock's reentrant acquisition silently skip
|
||||
the other's kernel-level flock.
|
||||
"""
|
||||
if getattr(holder, "depth", 0) > 0:
|
||||
holder.depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_auth_lock_holder.depth -= 1
|
||||
holder.depth -= 1
|
||||
return
|
||||
|
||||
lock_path = _auth_lock_path()
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if fcntl is None and msvcrt is None:
|
||||
_auth_lock_holder.depth = 1
|
||||
holder.depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_auth_lock_holder.depth = 0
|
||||
holder.depth = 0
|
||||
return
|
||||
|
||||
# On Windows, msvcrt.locking needs the file to have content and the
|
||||
# file pointer at position 0. Ensure the lock file has at least 1 byte.
|
||||
# file pointer at position 0. Ensure the lock file has at least 1 byte.
|
||||
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
|
||||
lock_path.write_text(" ", encoding="utf-8")
|
||||
|
||||
with lock_path.open("r+" if msvcrt else "a+") as lock_file:
|
||||
deadline = time.time() + max(1.0, timeout_seconds)
|
||||
deadline = time.monotonic() + max(1.0, timeout_seconds)
|
||||
while True:
|
||||
try:
|
||||
if fcntl:
|
||||
@@ -825,15 +904,15 @@ def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
break
|
||||
except (BlockingIOError, OSError, PermissionError):
|
||||
if time.time() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for auth store lock")
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError(timeout_message)
|
||||
time.sleep(0.05)
|
||||
|
||||
_auth_lock_holder.depth = 1
|
||||
holder.depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_auth_lock_holder.depth = 0
|
||||
holder.depth = 0
|
||||
if fcntl:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
elif msvcrt:
|
||||
@@ -844,6 +923,25 @@ def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _auth_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-process advisory lock for auth.json reads+writes. Reentrant.
|
||||
|
||||
Lock ordering invariant: when this lock is held together with
|
||||
``_nous_shared_store_lock``, acquire ``_auth_store_lock`` FIRST
|
||||
(outer) and the shared Nous lock SECOND (inner). All runtime
|
||||
refresh paths follow this order; violating it risks deadlock
|
||||
against a concurrent import on the shared store.
|
||||
"""
|
||||
with _file_lock(
|
||||
_auth_lock_path(),
|
||||
_auth_lock_holder,
|
||||
timeout_seconds,
|
||||
"Timed out waiting for auth store lock",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
auth_file = auth_file or _auth_file_path()
|
||||
if not auth_file.exists():
|
||||
@@ -887,12 +985,27 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
auth_file = _auth_file_path()
|
||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
||||
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
||||
try:
|
||||
os.chmod(auth_file.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
auth_store["version"] = AUTH_STORE_VERSION
|
||||
auth_store["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
payload = json.dumps(auth_store, indent=2) + "\n"
|
||||
tmp_path = auth_file.with_name(f"{auth_file.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
try:
|
||||
with tmp_path.open("w", encoding="utf-8") as handle:
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) + fdopen to close
|
||||
# the TOCTOU window where default umask (often 0o644) briefly exposed
|
||||
# OAuth tokens to other local users between open() and chmod().
|
||||
# Mirrors agent/google_oauth.py (#19673) and tools/mcp_oauth.py (#21148).
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
@@ -966,15 +1079,50 @@ def get_auth_provider_display_name(provider_id: str) -> str:
|
||||
|
||||
|
||||
def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Return the persisted credential pool, or one provider slice."""
|
||||
"""Return the persisted credential pool, or one provider slice.
|
||||
|
||||
In profile mode, the profile's credential pool is authoritative. If a
|
||||
provider has no entries in the profile, entries from the global-root
|
||||
``auth.json`` are used as a read-only fallback — so workers spawned in a
|
||||
profile can see providers that were only authenticated at global scope.
|
||||
|
||||
Profile entries always win: the global fallback only applies per-provider
|
||||
when the profile has zero entries for that provider. Once the user runs
|
||||
``hermes auth add <provider>`` inside the profile, profile entries
|
||||
fully shadow global for that provider on the next read.
|
||||
|
||||
Writes always go to the profile (``write_credential_pool`` is unchanged).
|
||||
See issue #18594 follow-up.
|
||||
"""
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
|
||||
global_pool: Dict[str, Any] = {}
|
||||
global_store = _load_global_auth_store()
|
||||
maybe_global_pool = global_store.get("credential_pool") if global_store else None
|
||||
if isinstance(maybe_global_pool, dict):
|
||||
global_pool = maybe_global_pool
|
||||
|
||||
if provider_id is None:
|
||||
return dict(pool)
|
||||
merged = dict(pool)
|
||||
for gp_key, gp_entries in global_pool.items():
|
||||
if not isinstance(gp_entries, list) or not gp_entries:
|
||||
continue
|
||||
# Per-provider shadowing: profile wins whenever it has ANY entries.
|
||||
existing = merged.get(gp_key)
|
||||
if isinstance(existing, list) and existing:
|
||||
continue
|
||||
merged[gp_key] = list(gp_entries)
|
||||
return merged
|
||||
|
||||
provider_entries = pool.get(provider_id)
|
||||
return list(provider_entries) if isinstance(provider_entries, list) else []
|
||||
if isinstance(provider_entries, list) and provider_entries:
|
||||
return list(provider_entries)
|
||||
# Profile has no entries for this provider — fall back to global.
|
||||
global_entries = global_pool.get(provider_id)
|
||||
return list(global_entries) if isinstance(global_entries, list) else []
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
@@ -1033,9 +1181,25 @@ def unsuppress_credential_source(provider_id: str, source: str) -> bool:
|
||||
|
||||
|
||||
def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return persisted auth state for a provider, or None."""
|
||||
"""Return persisted auth state for a provider, or None.
|
||||
|
||||
In profile mode, falls back to the global-root ``auth.json`` when the
|
||||
profile has no state for this provider. Profile state always wins when
|
||||
present. Writes (``_save_auth_store`` / ``persist_*_credentials``) are
|
||||
unchanged — they still target the profile only. This mirrors
|
||||
``read_credential_pool``'s per-provider shadowing semantics so that
|
||||
``_seed_from_singletons`` can reseed a profile's credential pool from
|
||||
global-scope provider state (e.g. a globally-authenticated Anthropic
|
||||
OAuth or Nous device-code session). See issue #18594 follow-up.
|
||||
"""
|
||||
auth_store = _load_auth_store()
|
||||
return _load_provider_state(auth_store, provider_id)
|
||||
state = _load_provider_state(auth_store, provider_id)
|
||||
if state is not None:
|
||||
return state
|
||||
global_store = _load_global_auth_store()
|
||||
if not global_store:
|
||||
return None
|
||||
return _load_provider_state(global_store, provider_id)
|
||||
|
||||
|
||||
def get_active_provider() -> Optional[str]:
|
||||
@@ -1405,10 +1569,33 @@ def _read_qwen_cli_tokens() -> Dict[str, Any]:
|
||||
def _save_qwen_cli_tokens(tokens: Dict[str, Any]) -> Path:
|
||||
auth_path = _qwen_cli_auth_path()
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = auth_path.with_suffix(".tmp")
|
||||
tmp_path.write_text(json.dumps(tokens, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
tmp_path.replace(auth_path)
|
||||
try:
|
||||
os.chmod(auth_path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
# Per-process random temp suffix avoids collisions between concurrent
|
||||
# writers and stale leftovers from a crashed prior write.
|
||||
tmp_path = auth_path.with_name(f"{auth_path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU
|
||||
# window where write_text() + post-write chmod briefly exposed tokens
|
||||
# at process umask (typically 0o644). See #19673, #21148.
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(tokens, indent=2, sort_keys=True) + "\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
atomic_replace(tmp_path, auth_path)
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return auth_path
|
||||
|
||||
|
||||
@@ -1825,9 +2012,9 @@ def _spotify_wait_for_callback(
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.1}, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + max(5.0, timeout_seconds)
|
||||
deadline = time.monotonic() + max(5.0, timeout_seconds)
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
while time.monotonic() < deadline:
|
||||
if result["code"] or result["error"]:
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
@@ -2590,10 +2777,10 @@ def _poll_for_token(
|
||||
poll_interval: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Poll the token endpoint until the user approves or the code expires."""
|
||||
deadline = time.time() + max(1, expires_in)
|
||||
deadline = time.monotonic() + max(1, expires_in)
|
||||
current_interval = max(1, min(poll_interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
|
||||
|
||||
while time.time() < deadline:
|
||||
while time.monotonic() < deadline:
|
||||
response = client.post(
|
||||
f"{portal_base_url}/api/oauth/token",
|
||||
data={
|
||||
@@ -2651,6 +2838,7 @@ def _poll_for_token(
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
|
||||
_nous_shared_lock_holder = threading.local()
|
||||
|
||||
|
||||
def _nous_shared_auth_dir() -> Path:
|
||||
@@ -2690,6 +2878,69 @@ def _nous_shared_store_path() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
|
||||
"""Cross-profile lock for the shared Nous OAuth store.
|
||||
|
||||
Lock ordering invariant: if both this and ``_auth_store_lock`` need
|
||||
to be held, acquire ``_auth_store_lock`` FIRST. All runtime refresh
|
||||
paths follow this order. The one exception is
|
||||
``_try_import_shared_nous_state``, which holds this lock alone for
|
||||
the entire refresh+mint cycle so concurrent imports on sibling
|
||||
profiles can't race on the single-use shared refresh token; that
|
||||
helper must NOT be called with ``_auth_store_lock`` already held.
|
||||
"""
|
||||
try:
|
||||
lock_path = _nous_shared_store_path().with_suffix(".lock")
|
||||
except RuntimeError:
|
||||
# No HERMES_HOME yet (pre-setup): fall through without locking.
|
||||
yield
|
||||
return
|
||||
|
||||
with _file_lock(
|
||||
lock_path,
|
||||
_nous_shared_lock_holder,
|
||||
timeout_seconds,
|
||||
"Timed out waiting for shared Nous auth lock",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool:
|
||||
"""Copy fresher shared OAuth tokens into a profile-local Nous state."""
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return False
|
||||
|
||||
shared_refresh = shared.get("refresh_token")
|
||||
if not isinstance(shared_refresh, str) or not shared_refresh.strip():
|
||||
return False
|
||||
|
||||
local_refresh = state.get("refresh_token")
|
||||
shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0
|
||||
local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0
|
||||
refresh_changed = shared_refresh.strip() != str(local_refresh or "").strip()
|
||||
fresher_access = shared_access_exp > local_access_exp
|
||||
if not refresh_changed and not fresher_access:
|
||||
return False
|
||||
|
||||
for key in (
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"token_type",
|
||||
"scope",
|
||||
"client_id",
|
||||
"portal_base_url",
|
||||
"inference_base_url",
|
||||
"obtained_at",
|
||||
"expires_at",
|
||||
):
|
||||
value = shared.get(key)
|
||||
if value not in (None, ""):
|
||||
state[key] = value
|
||||
return True
|
||||
|
||||
|
||||
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
|
||||
"""Persist a minimal copy of the Nous OAuth state to the shared store.
|
||||
|
||||
@@ -2722,15 +2973,34 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None:
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
try:
|
||||
path = _nous_shared_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(shared, indent=2, sort_keys=True))
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
with _nous_shared_store_lock():
|
||||
path = _nous_shared_store_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
# Create with 0o600 atomically via os.open(O_EXCL) — closes the TOCTOU
|
||||
# window where write_text() + post-write chmod briefly exposed Nous
|
||||
# refresh_token at process umask. See #19673, #21148.
|
||||
fd = os.open(
|
||||
str(tmp),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(shared, indent=2, sort_keys=True))
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_oauth_trace(
|
||||
"nous_shared_store_written",
|
||||
path=str(path),
|
||||
@@ -2787,36 +3057,38 @@ def _try_import_shared_nous_state(
|
||||
etc.) — caller should then fall through to the normal device-code
|
||||
flow.
|
||||
"""
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return None
|
||||
|
||||
# Build a full state dict so refresh_nous_oauth_from_state has every
|
||||
# field it needs. force_refresh=True gets us a fresh access_token
|
||||
# for this profile; force_mint=True gets us a fresh agent_key.
|
||||
state: Dict[str, Any] = {
|
||||
"access_token": shared.get("access_token"),
|
||||
"refresh_token": shared.get("refresh_token"),
|
||||
"client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
|
||||
"portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
|
||||
"inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
|
||||
"token_type": shared.get("token_type") or "Bearer",
|
||||
"scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
|
||||
"obtained_at": shared.get("obtained_at"),
|
||||
"expires_at": shared.get("expires_at"),
|
||||
"agent_key": None,
|
||||
"agent_key_expires_at": None,
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
}
|
||||
|
||||
try:
|
||||
refreshed = refresh_nous_oauth_from_state(
|
||||
state,
|
||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
force_refresh=True,
|
||||
force_mint=True,
|
||||
)
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
shared = _read_shared_nous_state()
|
||||
if not shared:
|
||||
return None
|
||||
|
||||
# Build a full state dict so refresh_nous_oauth_from_state has every
|
||||
# field it needs. force_refresh=True gets us a fresh access_token
|
||||
# for this profile; force_mint=True gets us a fresh agent_key.
|
||||
state: Dict[str, Any] = {
|
||||
"access_token": shared.get("access_token"),
|
||||
"refresh_token": shared.get("refresh_token"),
|
||||
"client_id": shared.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
|
||||
"portal_base_url": shared.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
|
||||
"inference_base_url": shared.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
|
||||
"token_type": shared.get("token_type") or "Bearer",
|
||||
"scope": shared.get("scope") or DEFAULT_NOUS_SCOPE,
|
||||
"obtained_at": shared.get("obtained_at"),
|
||||
"expires_at": shared.get("expires_at"),
|
||||
"agent_key": None,
|
||||
"agent_key_expires_at": None,
|
||||
"tls": {"insecure": False, "ca_bundle": None},
|
||||
}
|
||||
|
||||
refreshed = refresh_nous_oauth_from_state(
|
||||
state,
|
||||
min_key_ttl_seconds=min_key_ttl_seconds,
|
||||
timeout_seconds=timeout_seconds,
|
||||
force_refresh=True,
|
||||
force_mint=True,
|
||||
)
|
||||
_write_shared_nous_state(refreshed)
|
||||
except AuthError as exc:
|
||||
_oauth_trace(
|
||||
"nous_shared_import_failed",
|
||||
@@ -3018,59 +3290,65 @@ def resolve_nous_access_token(
|
||||
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
|
||||
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||||
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise AuthError(
|
||||
"No access token found for Nous Portal login.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
merged_shared = _merge_shared_nous_oauth_state(state)
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
raise AuthError(
|
||||
"No access token found for Nous Portal login.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||
return access_token
|
||||
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||
if merged_shared:
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
return access_token
|
||||
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError(
|
||||
"Session expired and no refresh token is available.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError(
|
||||
"Session expired and no refresh token is available.",
|
||||
provider="nous",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
verify=verify,
|
||||
) as client:
|
||||
refreshed = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||
with httpx.Client(
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
verify=verify,
|
||||
) as client:
|
||||
refreshed = _refresh_access_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
state["portal_base_url"] = portal_base_url
|
||||
state["client_id"] = client_id
|
||||
state["tls"] = {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
}
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
return state["access_token"]
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
state["portal_base_url"] = portal_base_url
|
||||
state["client_id"] = client_id
|
||||
state["tls"] = {
|
||||
"insecure": verify is False,
|
||||
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||
}
|
||||
_save_provider_state(auth_store, "nous", state)
|
||||
_save_auth_store(auth_store)
|
||||
_write_shared_nous_state(state)
|
||||
return state["access_token"]
|
||||
|
||||
|
||||
def refresh_nous_oauth_pure(
|
||||
@@ -3338,46 +3616,53 @@ def resolve_nous_runtime_credentials(
|
||||
|
||||
# Step 1: refresh access token if expiring
|
||||
if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError("Session expired and no refresh token is available.",
|
||||
provider="nous", relogin_required=True)
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
if _merge_shared_nous_oauth_state(state):
|
||||
access_token = state.get("access_token")
|
||||
refresh_token = state.get("refresh_token")
|
||||
_persist_state("post_shared_merge_access_expiring")
|
||||
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
previous_refresh_token = refresh_token
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
|
||||
_persist_state("post_refresh_access_expiring")
|
||||
if _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS):
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
raise AuthError("Session expired and no refresh token is available.",
|
||||
provider="nous", relogin_required=True)
|
||||
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
previous_refresh_token = refresh_token
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="access_expiring",
|
||||
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist immediately so downstream mint failures cannot drop rotated refresh tokens.
|
||||
_persist_state("post_refresh_access_expiring")
|
||||
|
||||
# Step 2: mint agent key if missing/expiring
|
||||
used_cached_key = False
|
||||
@@ -3410,41 +3695,47 @@ def resolve_nous_runtime_credentials(
|
||||
and isinstance(latest_refresh_token, str)
|
||||
and latest_refresh_token
|
||||
):
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=latest_refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist retry refresh immediately for crash safety and cross-process visibility.
|
||||
_persist_state("post_refresh_mint_retry")
|
||||
with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)):
|
||||
if _merge_shared_nous_oauth_state(state):
|
||||
access_token = state.get("access_token")
|
||||
latest_refresh_token = state.get("refresh_token")
|
||||
_persist_state("post_shared_merge_mint_retry")
|
||||
else:
|
||||
_oauth_trace(
|
||||
"refresh_start",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
)
|
||||
refreshed = _refresh_access_token(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
client_id=client_id, refresh_token=latest_refresh_token,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||
state["access_token"] = refreshed["access_token"]
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
state["expires_in"] = access_ttl
|
||||
state["expires_at"] = datetime.fromtimestamp(
|
||||
now.timestamp() + access_ttl, tz=timezone.utc
|
||||
).isoformat()
|
||||
access_token = state["access_token"]
|
||||
refresh_token = state["refresh_token"]
|
||||
_oauth_trace(
|
||||
"refresh_success",
|
||||
sequence_id=sequence_id,
|
||||
reason="mint_retry_after_invalid_token",
|
||||
previous_refresh_token_fp=_token_fingerprint(latest_refresh_token),
|
||||
new_refresh_token_fp=_token_fingerprint(refresh_token),
|
||||
)
|
||||
# Persist retry refresh immediately for crash safety and cross-process visibility.
|
||||
_persist_state("post_refresh_mint_retry")
|
||||
|
||||
mint_payload = _mint_agent_key(
|
||||
client=client, portal_base_url=portal_base_url,
|
||||
@@ -3940,6 +4231,14 @@ def _config_provider_matches(provider_id: Optional[str]) -> bool:
|
||||
return _get_config_provider() == provider_id.strip().lower()
|
||||
|
||||
|
||||
def _should_reset_config_provider_on_logout(provider_id: Optional[str]) -> bool:
|
||||
"""Return True when logout should reset the model provider config."""
|
||||
if not provider_id:
|
||||
return False
|
||||
normalized = provider_id.strip().lower()
|
||||
return normalized in PROVIDER_REGISTRY and _config_provider_matches(normalized)
|
||||
|
||||
|
||||
def _logout_default_provider_from_config() -> Optional[str]:
|
||||
"""Fallback logout target when auth.json has no active provider.
|
||||
|
||||
@@ -5025,15 +5324,18 @@ def logout_command(args) -> None:
|
||||
print("No provider is currently logged in.")
|
||||
return
|
||||
|
||||
config_matches = _config_provider_matches(target)
|
||||
should_reset_config = _should_reset_config_provider_on_logout(target)
|
||||
provider_name = get_auth_provider_display_name(target)
|
||||
|
||||
if clear_provider_auth(target) or config_matches:
|
||||
_reset_config_provider()
|
||||
if clear_provider_auth(target) or should_reset_config:
|
||||
if should_reset_config:
|
||||
_reset_config_provider()
|
||||
print(f"Logged out of {provider_name}.")
|
||||
if os.getenv("OPENROUTER_API_KEY"):
|
||||
if should_reset_config and os.getenv("OPENROUTER_API_KEY"):
|
||||
print("Hermes will use OpenRouter for inference.")
|
||||
else:
|
||||
elif should_reset_config:
|
||||
print("Run `hermes model` or configure an API key to use Hermes.")
|
||||
else:
|
||||
print("Model provider configuration was unchanged.")
|
||||
else:
|
||||
print(f"No auth state found for {provider_name}.")
|
||||
|
||||
@@ -109,6 +109,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("resume", "Resume a previously-named session", "Session",
|
||||
args_hint="[name]"),
|
||||
|
||||
# Configuration
|
||||
CommandDef("sessions", "Browse and resume previous sessions", "Session"),
|
||||
|
||||
# Configuration
|
||||
CommandDef("config", "Show current configuration", "Configuration",
|
||||
cli_only=True),
|
||||
@@ -157,9 +160,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive)",
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore")),
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),
|
||||
CommandDef("kanban", "Multi-profile collaboration board (tasks, links, comments)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("list", "ls", "show", "create", "assign", "link", "unlink",
|
||||
|
||||
+256
-102
@@ -21,6 +21,7 @@ import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
@@ -42,6 +43,14 @@ _LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want
|
||||
# the user's on-disk values without defaults merged in.
|
||||
_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}
|
||||
# Serializes all config read/write paths. libyaml's C extension is not
|
||||
# thread-safe for concurrent safe_load() on the same file, and multiple
|
||||
# tool threads (approval.py, browser_tool.py, setup flows) hit
|
||||
# load_config / read_raw_config / save_config from different threads
|
||||
# during long agent runs. RLock (not Lock) because save_config internally
|
||||
# calls read_raw_config. Also covers mutation of the module-level cache
|
||||
# dicts above.
|
||||
_CONFIG_LOCK = threading.RLock()
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
# (managed by setup/provider flows directly).
|
||||
_EXTRA_ENV_KEYS = frozenset({
|
||||
@@ -780,6 +789,19 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Triage specifier — flesh out a rough one-liner in the Kanban
|
||||
# Triage column into a concrete spec, then promote it to ``todo``.
|
||||
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
|
||||
# cheap, capable model here (gemini-flash works well); the main
|
||||
# model is overkill for short spec expansion.
|
||||
"triage_specifier": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 120,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Curator — skill-usage review fork. Timeout is generous because the
|
||||
# review pass can take several minutes on reasoning models (umbrella
|
||||
# building over hundreds of candidate skills). "auto" = use main chat
|
||||
@@ -1106,6 +1128,14 @@ DEFAULT_CONFIG = {
|
||||
# Empty string means use server-local time.
|
||||
"timezone": "",
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"require_mention": True, # Require @mention to respond in channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Discord platform settings (gateway mode)
|
||||
"discord": {
|
||||
"require_mention": True, # Require @mention to respond in server channels
|
||||
@@ -1114,6 +1144,12 @@ DEFAULT_CONFIG = {
|
||||
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
|
||||
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
|
||||
# Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES
|
||||
# authorizes only guild messages in the role's own guild — DMs require
|
||||
# DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also
|
||||
# authorize DMs from members of that one trusted guild holding the
|
||||
# allowed role. Unset / empty / 0 = secure default (DM role-auth off).
|
||||
"dm_role_auth_guild": "",
|
||||
# discord / discord_admin tools: restrict which actions the agent may call.
|
||||
# Default (empty) = all actions allowed (subject to bot privileged intents).
|
||||
# Accepts comma-separated string ("list_guilds,list_channels,fetch_messages")
|
||||
@@ -1136,18 +1172,24 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"reactions": False, # Add 👀/✅/❌ reactions to messages during processing
|
||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
},
|
||||
|
||||
# Slack platform settings (gateway mode)
|
||||
"slack": {
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||||
},
|
||||
|
||||
# Mattermost platform settings (gateway mode)
|
||||
"mattermost": {
|
||||
"require_mention": True, # Require @mention to respond in channels
|
||||
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
|
||||
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
|
||||
"channel_prompts": {}, # Per-channel ephemeral system prompts
|
||||
},
|
||||
|
||||
# Matrix platform settings (gateway mode)
|
||||
"matrix": {
|
||||
"require_mention": True, # Require @mention to respond in rooms
|
||||
"free_response_rooms": "", # Comma-separated room IDs where bot responds without mention
|
||||
"allowed_rooms": "", # If set, bot ONLY responds in these room IDs (whitelist)
|
||||
},
|
||||
|
||||
# Approval mode for dangerous commands:
|
||||
# manual — always prompt the user (default)
|
||||
# smart — use auxiliary LLM to auto-approve low-risk commands, prompt for high-risk
|
||||
@@ -1197,7 +1239,7 @@ DEFAULT_CONFIG = {
|
||||
# Pre-exec security scanning via tirith
|
||||
"security": {
|
||||
"allow_private_urls": False, # Allow requests to private/internal IPs (for OpenWrt, proxies, VPNs)
|
||||
"redact_secrets": False,
|
||||
"redact_secrets": True,
|
||||
"tirith_enabled": True,
|
||||
"tirith_path": "tirith",
|
||||
"tirith_timeout": 5,
|
||||
@@ -1236,6 +1278,10 @@ DEFAULT_CONFIG = {
|
||||
# Seconds between dispatcher ticks (idle or not). Lower = snappier
|
||||
# pickup of newly-ready tasks; higher = less SQL pressure.
|
||||
"dispatch_interval_seconds": 60,
|
||||
# Auto-block after this many consecutive non-success attempts for the
|
||||
# same task/profile (spawn_failed, timed_out, or crashed). Reassignment
|
||||
# resets the streak for the new profile.
|
||||
"failure_limit": 2,
|
||||
},
|
||||
|
||||
# execute_code settings — controls the tool used for programmatic tool calls.
|
||||
@@ -1846,6 +1892,14 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
},
|
||||
"BRAVE_SEARCH_API_KEY": {
|
||||
"description": "Brave Search API subscription token (free tier: 2,000 queries/mo)",
|
||||
"prompt": "Brave Search subscription token",
|
||||
"url": "https://brave.com/search/api/",
|
||||
"tools": ["web_search"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"BROWSERBASE_API_KEY": {
|
||||
"description": "Browserbase API key for cloud browser (optional — local browser works without this)",
|
||||
"prompt": "Browserbase API key",
|
||||
@@ -3903,28 +3957,29 @@ def read_raw_config() -> Dict[str, Any]:
|
||||
``load_config()``. Returns a deepcopy on every call since some callers
|
||||
mutate the result before passing to ``save_config()``.
|
||||
"""
|
||||
try:
|
||||
config_path = get_config_path()
|
||||
st = config_path.stat()
|
||||
cache_key = (st.st_mtime_ns, st.st_size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
with _CONFIG_LOCK:
|
||||
try:
|
||||
config_path = get_config_path()
|
||||
st = config_path.stat()
|
||||
cache_key = (st.st_mtime_ns, st.st_size)
|
||||
except (FileNotFoundError, OSError):
|
||||
return {}
|
||||
|
||||
path_key = str(config_path)
|
||||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
path_key = str(config_path)
|
||||
cached = _RAW_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
_RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data))
|
||||
return data
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
@@ -3937,54 +3992,55 @@ def load_config() -> Dict[str, Any]:
|
||||
(which change ``HERMES_HOME`` and therefore ``get_config_path()``)
|
||||
don't collide.
|
||||
"""
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
path_key = str(config_path)
|
||||
with _CONFIG_LOCK:
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
path_key = str(config_path)
|
||||
|
||||
try:
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
if cache_key is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
st = config_path.stat()
|
||||
cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size)
|
||||
except FileNotFoundError:
|
||||
cache_key = None
|
||||
|
||||
if "max_turns" in user_config:
|
||||
agent_user_config = dict(user_config.get("agent") or {})
|
||||
if agent_user_config.get("max_turns") is None:
|
||||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||||
user_config["agent"] = agent_user_config
|
||||
user_config.pop("max_turns", None)
|
||||
cached = _LOAD_CONFIG_CACHE.get(path_key)
|
||||
if cached is not None and cache_key is not None and cached[:2] == cache_key:
|
||||
return copy.deepcopy(cached[2])
|
||||
|
||||
config = _deep_merge(config, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
|
||||
else:
|
||||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||||
return expanded
|
||||
if cache_key is not None:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
user_config = yaml.safe_load(f) or {}
|
||||
|
||||
if "max_turns" in user_config:
|
||||
agent_user_config = dict(user_config.get("agent") or {})
|
||||
if agent_user_config.get("max_turns") is None:
|
||||
agent_user_config["max_turns"] = user_config["max_turns"]
|
||||
user_config["agent"] = agent_user_config
|
||||
user_config.pop("max_turns", None)
|
||||
|
||||
config = _deep_merge(config, user_config)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config: {e}")
|
||||
|
||||
normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
expanded = _expand_env_vars(normalized)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded)
|
||||
if cache_key is not None:
|
||||
_LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded))
|
||||
else:
|
||||
_LOAD_CONFIG_CACHE.pop(path_key, None)
|
||||
return expanded
|
||||
|
||||
|
||||
_SECURITY_COMMENT = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is OFF by default — tool output (terminal stdout,
|
||||
# read_file results, web content) passes through unmodified. Set
|
||||
# redact_secrets to true to mask strings that look like API keys, tokens,
|
||||
# and passwords before they enter the model context and logs.
|
||||
# Secret redaction is ON by default — strings that look like API keys,
|
||||
# tokens, and passwords are masked in tool output, logs, and chat
|
||||
# responses before the model or user ever sees them. Set redact_secrets
|
||||
# to false to disable (e.g. when developing the redactor itself).
|
||||
# tirith pre-exec scanning is enabled by default when the tirith binary
|
||||
# is available. Configure via security.tirith_* keys or env vars
|
||||
# (TIRITH_ENABLED, TIRITH_BIN, TIRITH_TIMEOUT, TIRITH_FAIL_OPEN).
|
||||
@@ -4024,8 +4080,8 @@ _FALLBACK_COMMENT = """
|
||||
|
||||
_COMMENTED_SECTIONS = """
|
||||
# ── Security ──────────────────────────────────────────────────────────
|
||||
# Secret redaction is OFF by default. Set to true to mask strings that
|
||||
# look like API keys, tokens, and passwords in tool output and logs.
|
||||
# Secret redaction is ON by default. Set to false to pass tool output,
|
||||
# logs, and chat responses through unmodified (e.g. for redactor dev).
|
||||
#
|
||||
# security:
|
||||
# redact_secrets: true
|
||||
@@ -4056,45 +4112,46 @@ _COMMENTED_SECTIONS = """
|
||||
|
||||
def save_config(config: Dict[str, Any]):
|
||||
"""Save configuration to ~/.hermes/config.yaml."""
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
with _CONFIG_LOCK:
|
||||
if is_managed():
|
||||
managed_error("save configuration")
|
||||
return
|
||||
from utils import atomic_yaml_write
|
||||
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
normalized = current_normalized
|
||||
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
|
||||
if raw_existing:
|
||||
normalized = _preserve_env_ref_templates(
|
||||
ensure_hermes_home()
|
||||
config_path = get_config_path()
|
||||
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
|
||||
normalized = current_normalized
|
||||
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
|
||||
if raw_existing:
|
||||
normalized = _preserve_env_ref_templates(
|
||||
normalized,
|
||||
raw_existing,
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
|
||||
)
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
fb_is_valid = False
|
||||
if isinstance(fb, list):
|
||||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||||
elif isinstance(fb, dict):
|
||||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||||
if not fb_is_valid:
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
raw_existing,
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH.get(str(config_path)),
|
||||
extra_content="".join(parts) if parts else None,
|
||||
)
|
||||
|
||||
# Build optional commented-out sections for features that are off by
|
||||
# default or only relevant when explicitly configured.
|
||||
parts = []
|
||||
sec = normalized.get("security", {})
|
||||
if not sec or sec.get("redact_secrets") is None:
|
||||
parts.append(_SECURITY_COMMENT)
|
||||
fb = normalized.get("fallback_model", {})
|
||||
fb_is_valid = False
|
||||
if isinstance(fb, list):
|
||||
fb_is_valid = any(isinstance(e, dict) and e.get("provider") and e.get("model") for e in fb)
|
||||
elif isinstance(fb, dict):
|
||||
fb_is_valid = bool(fb.get("provider") and fb.get("model"))
|
||||
if not fb_is_valid:
|
||||
parts.append(_FALLBACK_COMMENT)
|
||||
|
||||
atomic_yaml_write(
|
||||
config_path,
|
||||
normalized,
|
||||
extra_content="".join(parts) if parts else None,
|
||||
)
|
||||
_secure_file(config_path)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
|
||||
_secure_file(config_path)
|
||||
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)
|
||||
|
||||
|
||||
def load_env() -> Dict[str, str]:
|
||||
@@ -4944,3 +5001,100 @@ def _inject_profile_env_vars() -> None:
|
||||
|
||||
# Eagerly inject so that OPTIONAL_ENV_VARS is fully populated at import time.
|
||||
_inject_profile_env_vars()
|
||||
|
||||
|
||||
# ── Platform-plugin env var injection ────────────────────────────────────────
|
||||
# Bundled platform plugins under ``plugins/platforms/*/plugin.yaml`` declare
|
||||
# their required env vars via ``requires_env``. This mirror of
|
||||
# ``_inject_profile_env_vars`` surfaces them in ``hermes config`` UI so users
|
||||
# can configure Teams / IRC / Google Chat without the core repo ever needing
|
||||
# to know they exist.
|
||||
#
|
||||
# Each ``requires_env`` entry may be a bare string (name only) or a dict:
|
||||
#
|
||||
# requires_env:
|
||||
# - TEAMS_CLIENT_ID # minimal
|
||||
# - name: TEAMS_CLIENT_SECRET # rich
|
||||
# description: "Teams bot client secret"
|
||||
# url: "https://portal.azure.com/"
|
||||
# password: true
|
||||
# prompt: "Teams client secret"
|
||||
#
|
||||
# An optional ``optional_env`` block surfaces non-required vars the same way
|
||||
# (e.g. allowlist, home channel).
|
||||
|
||||
_platform_plugin_env_vars_injected = False
|
||||
|
||||
|
||||
def _inject_platform_plugin_env_vars() -> None:
|
||||
"""Populate OPTIONAL_ENV_VARS from bundled platform plugin manifests.
|
||||
|
||||
Called once at module load time. Idempotent — repeated calls are no-ops.
|
||||
Failures are swallowed so a malformed plugin.yaml can't break CLI import.
|
||||
"""
|
||||
global _platform_plugin_env_vars_injected
|
||||
if _platform_plugin_env_vars_injected:
|
||||
return
|
||||
_platform_plugin_env_vars_injected = True
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
|
||||
# Resolve the bundled plugins dir from this file's location so the
|
||||
# injector works regardless of CWD.
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
platforms_dir = repo_root / "plugins" / "platforms"
|
||||
if not platforms_dir.is_dir():
|
||||
return
|
||||
for child in platforms_dir.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
manifest_path = child / "plugin.yaml"
|
||||
if not manifest_path.exists():
|
||||
manifest_path = child / "plugin.yml"
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
continue
|
||||
label = manifest.get("label") or manifest.get("name") or child.name
|
||||
# Merge required + optional env var declarations.
|
||||
entries = list(manifest.get("requires_env") or [])
|
||||
entries.extend(manifest.get("optional_env") or [])
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
name = entry
|
||||
meta: dict = {}
|
||||
elif isinstance(entry, dict) and entry.get("name"):
|
||||
name = entry["name"]
|
||||
meta = entry
|
||||
else:
|
||||
continue
|
||||
if name in OPTIONAL_ENV_VARS:
|
||||
continue # hardcoded entry wins (back-compat)
|
||||
# Heuristic: anything named *TOKEN, *SECRET, *KEY, *PASSWORD
|
||||
# is a password field unless explicitly overridden.
|
||||
name_upper = name.upper()
|
||||
is_secret = bool(meta.get("password") or meta.get("secret"))
|
||||
if not is_secret and not meta.get("password") is False:
|
||||
is_secret = any(
|
||||
name_upper.endswith(suf)
|
||||
for suf in ("_TOKEN", "_SECRET", "_KEY", "_PASSWORD", "_JSON")
|
||||
)
|
||||
OPTIONAL_ENV_VARS[name] = {
|
||||
"description": (
|
||||
meta.get("description")
|
||||
or f"{label} configuration"
|
||||
),
|
||||
"prompt": meta.get("prompt") or name,
|
||||
"url": meta.get("url") or None,
|
||||
"password": is_secret,
|
||||
"category": meta.get("category") or "messaging",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Eagerly inject so that platform plugin env vars show up in the setup wizard.
|
||||
_inject_platform_plugin_env_vars()
|
||||
|
||||
@@ -212,9 +212,9 @@ def copilot_device_code_login(
|
||||
print(" Waiting for authorization...", end="", flush=True)
|
||||
|
||||
# Step 3: Poll for completion
|
||||
deadline = time.time() + timeout_seconds
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
while time.time() < deadline:
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
|
||||
|
||||
poll_data = urllib.parse.urlencode({
|
||||
|
||||
+37
-8
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -57,7 +58,8 @@ def _cmd_status(args) -> int:
|
||||
print(f" last summary: {summary}")
|
||||
_report = state.get("last_report_path")
|
||||
if _report:
|
||||
print(f" last report: {_report}")
|
||||
suffix = "" if Path(_report).exists() else " (missing)"
|
||||
print(f" last report: {_report}{suffix}")
|
||||
_ih = curator.get_interval_hours()
|
||||
_interval_label = (
|
||||
f"{_ih // 24}d" if _ih % 24 == 0 and _ih >= 24
|
||||
@@ -161,6 +163,8 @@ def _cmd_run(args) -> int:
|
||||
return 1
|
||||
|
||||
dry = bool(getattr(args, "dry_run", False))
|
||||
background = bool(getattr(args, "background", False))
|
||||
synchronous = bool(getattr(args, "synchronous", False)) or not background
|
||||
if dry:
|
||||
print("curator: running DRY-RUN (report only, no mutations)...")
|
||||
else:
|
||||
@@ -171,7 +175,7 @@ def _cmd_run(args) -> int:
|
||||
|
||||
result = curator.run_curator_review(
|
||||
on_summary=_on_summary,
|
||||
synchronous=bool(args.synchronous),
|
||||
synchronous=synchronous,
|
||||
dry_run=dry,
|
||||
)
|
||||
auto = result.get("auto_transitions", {})
|
||||
@@ -188,13 +192,19 @@ def _cmd_run(args) -> int:
|
||||
f"archived={auto.get('archived', 0)} "
|
||||
f"reactivated={auto.get('reactivated', 0)}"
|
||||
)
|
||||
if not args.synchronous:
|
||||
if not synchronous:
|
||||
print("llm pass running in background — check `hermes curator status` later")
|
||||
if dry:
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
if synchronous:
|
||||
print(
|
||||
"dry-run: no changes applied. Read the report with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"dry-run: no changes applied. When the report lands, read it with "
|
||||
"`hermes curator status` and run `hermes curator run` (no flag) to apply."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -442,6 +452,18 @@ def _cmd_rollback(args) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_list_archived(args) -> int:
|
||||
"""List archived (recoverable) skills."""
|
||||
from tools import skill_usage
|
||||
names = skill_usage.list_archived_skill_names()
|
||||
if not names:
|
||||
print("curator: no archived skills")
|
||||
return 0
|
||||
for name in names:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -461,7 +483,11 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
||||
p_run.add_argument(
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
help="Wait for the LLM review pass to finish (default: background thread)",
|
||||
help="Wait for the LLM review pass to finish (default for manual runs)",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--background", dest="background", action="store_true",
|
||||
help="Start the LLM review pass in a background thread and return immediately",
|
||||
)
|
||||
p_run.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
@@ -488,6 +514,9 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_restore.add_argument("skill", help="Skill name")
|
||||
p_restore.set_defaults(func=_cmd_restore)
|
||||
|
||||
subs.add_parser("list-archived", help="List archived skills") \
|
||||
.set_defaults(func=_cmd_list_archived)
|
||||
|
||||
p_archive = subs.add_parser(
|
||||
"archive",
|
||||
help="Manually archive a skill (move to .archive/, excluded from prompt)",
|
||||
|
||||
@@ -91,6 +91,15 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
|
||||
return steps
|
||||
|
||||
|
||||
def _termux_install_all_fallback_notes() -> list[str]:
|
||||
return [
|
||||
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
|
||||
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
|
||||
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
|
||||
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
|
||||
]
|
||||
|
||||
|
||||
def _has_provider_env_config(content: str) -> bool:
|
||||
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
|
||||
return any(key in content for key in _PROVIDER_ENV_HINTS)
|
||||
@@ -1084,6 +1093,11 @@ def run_doctor(args):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _is_termux():
|
||||
check_info("Termux compatibility fallbacks:")
|
||||
for note in _termux_install_all_fallback_notes():
|
||||
check_info(note)
|
||||
|
||||
# =========================================================================
|
||||
# Check: API connectivity
|
||||
# =========================================================================
|
||||
@@ -1225,6 +1239,16 @@ def run_doctor(args):
|
||||
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:
|
||||
|
||||
+320
-39
@@ -505,6 +505,7 @@ def _read_systemd_unit_properties(
|
||||
"SubState",
|
||||
"Result",
|
||||
"ExecMainStatus",
|
||||
"MainPID",
|
||||
),
|
||||
) -> dict[str, str]:
|
||||
"""Return selected ``systemctl show`` properties for the gateway unit."""
|
||||
@@ -538,6 +539,41 @@ def _read_systemd_unit_properties(
|
||||
return parsed
|
||||
|
||||
|
||||
def _systemd_main_pid_from_props(props: dict[str, str]) -> int | None:
|
||||
try:
|
||||
pid = int(props.get("MainPID", "0") or "0")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return pid if pid > 0 else None
|
||||
|
||||
|
||||
def _systemd_main_pid(system: bool = False) -> int | None:
|
||||
return _systemd_main_pid_from_props(_read_systemd_unit_properties(system=system))
|
||||
|
||||
|
||||
def _read_gateway_runtime_status() -> dict | None:
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
|
||||
state = read_runtime_status()
|
||||
except Exception:
|
||||
return None
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _gateway_runtime_status_for_pid(pid: int | None) -> dict | None:
|
||||
if not pid:
|
||||
return None
|
||||
state = _read_gateway_runtime_status()
|
||||
if not state:
|
||||
return None
|
||||
try:
|
||||
state_pid = int(state.get("pid", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return state if state_pid == pid else None
|
||||
|
||||
|
||||
def _wait_for_systemd_service_restart(
|
||||
*,
|
||||
system: bool = False,
|
||||
@@ -549,9 +585,10 @@ def _wait_for_systemd_service_restart(
|
||||
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
deadline = time.time() + timeout
|
||||
deadline = time.monotonic() + timeout
|
||||
printed_runtime_wait = False
|
||||
|
||||
while time.time() < deadline:
|
||||
while time.monotonic() < deadline:
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
active_state = props.get("ActiveState", "")
|
||||
sub_state = props.get("SubState", "")
|
||||
@@ -562,19 +599,32 @@ def _wait_for_systemd_service_restart(
|
||||
new_pid = get_running_pid()
|
||||
except Exception:
|
||||
new_pid = None
|
||||
if not new_pid:
|
||||
new_pid = _systemd_main_pid_from_props(props)
|
||||
|
||||
if active_state == "active":
|
||||
if new_pid and (previous_pid is None or new_pid != previous_pid):
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if previous_pid is None:
|
||||
print(f"✓ {scope_label} service restarted")
|
||||
return True
|
||||
runtime_state = _gateway_runtime_status_for_pid(new_pid)
|
||||
gateway_state = (runtime_state or {}).get("gateway_state")
|
||||
if gateway_state == "running":
|
||||
print(f"✓ {scope_label} service restarted (PID {new_pid})")
|
||||
return True
|
||||
if gateway_state == "startup_failed":
|
||||
reason = (runtime_state or {}).get("exit_reason") or "startup failed"
|
||||
print(f"⚠ {scope_label} service process restarted (PID {new_pid}), but gateway startup failed: {reason}")
|
||||
return False
|
||||
if not printed_runtime_wait:
|
||||
print(f"⏳ {scope_label} service process started (PID {new_pid}); waiting for gateway runtime...")
|
||||
printed_runtime_wait = True
|
||||
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
if _systemd_unit_is_start_limited(props):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return False
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
print(
|
||||
@@ -585,6 +635,46 @@ def _wait_for_systemd_service_restart(
|
||||
return False
|
||||
|
||||
|
||||
def _systemd_unit_is_start_limited(props: dict[str, str]) -> bool:
|
||||
result = props.get("Result", "").lower()
|
||||
sub_state = props.get("SubState", "").lower()
|
||||
return result == "start-limit-hit" or sub_state == "start-limit-hit"
|
||||
|
||||
|
||||
def _systemd_error_indicates_start_limit(exc: subprocess.CalledProcessError) -> bool:
|
||||
parts: list[str] = []
|
||||
for attr in ("stderr", "stdout", "output"):
|
||||
value = getattr(exc, attr, None)
|
||||
if not value:
|
||||
continue
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(errors="replace")
|
||||
parts.append(str(value))
|
||||
text = "\n".join(parts).lower()
|
||||
return (
|
||||
"start-limit-hit" in text
|
||||
or "start request repeated too quickly" in text
|
||||
or "start-limit" in text
|
||||
)
|
||||
|
||||
|
||||
def _systemd_service_is_start_limited(system: bool = False) -> bool:
|
||||
return _systemd_unit_is_start_limited(_read_systemd_unit_properties(system=system))
|
||||
|
||||
|
||||
def _print_systemd_start_limit_wait(system: bool = False) -> None:
|
||||
svc = get_service_name()
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
scope_flag = " --system" if system else ""
|
||||
systemctl_prefix = "systemctl " if system else "systemctl --user "
|
||||
journal_prefix = "journalctl " if system else "journalctl --user "
|
||||
print(f"⏳ {scope_label} service is temporarily rate-limited by systemd.")
|
||||
print(" systemd is refusing another immediate start after repeated exits.")
|
||||
print(f" Wait for the start-limit window to expire, then run: {'sudo ' if system else ''}hermes gateway restart{scope_flag}")
|
||||
print(f" Or clear the failed state manually: {systemctl_prefix}reset-failed {svc}")
|
||||
print(f" Check logs: {journal_prefix}-u {svc} -l --since '5 min ago'")
|
||||
|
||||
|
||||
def _recover_pending_systemd_restart(system: bool = False, previous_pid: int | None = None) -> bool:
|
||||
"""Recover a planned service restart that is stuck in systemd state."""
|
||||
props = _read_systemd_unit_properties(system=system)
|
||||
@@ -740,6 +830,46 @@ def _print_other_profiles_gateway_status() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _gateway_list() -> None:
|
||||
"""List all profiles and their gateway running status.
|
||||
|
||||
Provides a single-command overview of every known profile and whether
|
||||
its gateway is currently running, so multi-profile users don't have to
|
||||
check each profile individually.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.profiles import list_profiles, get_active_profile_name
|
||||
except Exception:
|
||||
print("Unable to list profiles.")
|
||||
return
|
||||
|
||||
profiles = list_profiles()
|
||||
if not profiles:
|
||||
print("No profiles found.")
|
||||
return
|
||||
|
||||
current = get_active_profile_name()
|
||||
|
||||
print("Gateways:")
|
||||
for prof in profiles:
|
||||
marker = "✓" if prof.gateway_running else "✗"
|
||||
label = prof.name
|
||||
if prof.name == current:
|
||||
label += " (current)"
|
||||
parts = [f" {marker} {label:<24s}"]
|
||||
if prof.gateway_running:
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False)
|
||||
if pid:
|
||||
parts.append(f"PID {pid}")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
parts.append("not running")
|
||||
print(" — ".join(parts))
|
||||
|
||||
|
||||
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
|
||||
all_profiles: bool = False) -> int:
|
||||
"""Kill any running gateway processes. Returns count killed.
|
||||
@@ -967,6 +1097,27 @@ class UserSystemdUnavailableError(RuntimeError):
|
||||
"""
|
||||
|
||||
|
||||
class SystemScopeRequiresRootError(RuntimeError):
|
||||
"""Raised when a system-scope gateway operation is attempted as non-root.
|
||||
|
||||
System-scope units live in ``/etc/systemd/system/`` and require root for
|
||||
install / uninstall / start / stop / restart via ``systemctl``. The
|
||||
previous behavior was ``sys.exit(1)`` which blew past the wizard's
|
||||
``except Exception`` guards and dumped the user at a bare shell prompt
|
||||
with no guidance. Raising a typed exception lets callers that can
|
||||
recover (the setup wizard) print actionable remediation instead, while
|
||||
``gateway_command`` still exits 1 with the same message for the direct
|
||||
CLI path.
|
||||
|
||||
``args[0]`` carries the user-facing message, ``args[1]`` the action name.
|
||||
``str(e)`` returns only the message (not the tuple repr) so format
|
||||
strings like ``f"Failed: {e}"`` render cleanly.
|
||||
"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.args[0] if self.args else ""
|
||||
|
||||
|
||||
def _user_dbus_socket_path() -> Path:
|
||||
"""Return the expected per-user D-Bus socket path (regardless of existence)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
@@ -1382,8 +1533,10 @@ def print_systemd_scope_conflict_warning() -> None:
|
||||
|
||||
def _require_root_for_system_service(action: str) -> None:
|
||||
if os.geteuid() != 0:
|
||||
print(f"System gateway {action} requires root. Re-run with sudo.")
|
||||
sys.exit(1)
|
||||
raise SystemScopeRequiresRootError(
|
||||
f"System gateway {action} requires root. Re-run with sudo.",
|
||||
action,
|
||||
)
|
||||
|
||||
|
||||
def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str, str]:
|
||||
@@ -1930,6 +2083,47 @@ def _select_systemd_scope(system: bool = False) -> bool:
|
||||
return get_systemd_unit_path(system=True).exists() and not get_systemd_unit_path(system=False).exists()
|
||||
|
||||
|
||||
def _system_scope_wizard_would_need_root(system: bool = False) -> bool:
|
||||
"""True when the setup wizard is about to trigger a system-scope operation
|
||||
as a non-root user.
|
||||
|
||||
Replicates the decision ``_select_systemd_scope`` makes inside
|
||||
``systemd_start`` / ``systemd_restart`` / ``systemd_stop`` so the wizard
|
||||
can detect the dead-end BEFORE prompting, rather than letting
|
||||
``SystemScopeRequiresRootError`` propagate out and leave the user
|
||||
staring at a bare shell.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
return False
|
||||
return _select_systemd_scope(system=system)
|
||||
|
||||
|
||||
def _print_system_scope_remediation(action: str) -> None:
|
||||
"""Print actionable remediation when the wizard skips a system-scope
|
||||
prompt because the user isn't root. Keeps the wizard flowing instead of
|
||||
aborting.
|
||||
"""
|
||||
svc = get_service_name()
|
||||
print_warning(
|
||||
f"Gateway is installed as a system-wide service — "
|
||||
f"{action} requires root."
|
||||
)
|
||||
print_info(" Options:")
|
||||
print_info(f" 1. {action.capitalize()} it this time:")
|
||||
if action == "start":
|
||||
print_info(f" sudo systemctl start {svc}")
|
||||
elif action == "stop":
|
||||
print_info(f" sudo systemctl stop {svc}")
|
||||
elif action == "restart":
|
||||
print_info(f" sudo systemctl restart {svc}")
|
||||
else:
|
||||
print_info(f" sudo systemctl {action} {svc}")
|
||||
print_info(" 2. Switch to a per-user service (recommended for personal use):")
|
||||
print_info(" sudo hermes gateway uninstall --system")
|
||||
print_info(" hermes gateway install")
|
||||
print_info(" hermes gateway start")
|
||||
|
||||
|
||||
def _get_restart_drain_timeout() -> float:
|
||||
"""Return the configured gateway restart drain timeout in seconds."""
|
||||
raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
|
||||
@@ -2071,41 +2265,52 @@ def systemd_restart(system: bool = False):
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
import time
|
||||
pid = get_running_pid() or _systemd_main_pid(system=system)
|
||||
if pid is not None:
|
||||
scope_label = _service_scope_label(system).capitalize()
|
||||
svc = get_service_name()
|
||||
drain_timeout = _get_restart_drain_timeout()
|
||||
|
||||
# Phase 1: wait for old process to exit (drain + shutdown)
|
||||
print(f"⏳ {scope_label} service draining active work...")
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
time.sleep(1)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
break # old process is gone
|
||||
else:
|
||||
print(f"⚠ Old process (PID {pid}) still alive after 90s")
|
||||
print(f"⏳ {scope_label} service restarting gracefully (PID {pid})...")
|
||||
if _graceful_restart_via_sigusr1(pid, drain_timeout + 5):
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# RestartSec can otherwise delay the relaunch even though the
|
||||
# operator asked for an immediate restart, so kick the unit once
|
||||
# the old PID has exited and then wait for the replacement PID.
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["restart", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
if _wait_for_systemd_service_restart(system=system, previous_pid=pid):
|
||||
return
|
||||
if _systemd_service_is_start_limited(system=system):
|
||||
return
|
||||
|
||||
# The gateway exits with code 75 for a planned service restart.
|
||||
# systemd can sit in the RestartSec window or even wedge itself into a
|
||||
# failed/rate-limited state if the operator asks for another restart in
|
||||
# the middle of that handoff. Clear any stale failed state and kick the
|
||||
# unit immediately so `hermes gateway restart` behaves idempotently.
|
||||
print(
|
||||
f"⚠ Graceful restart did not complete within {int(drain_timeout + 5)}s; "
|
||||
"forcing a service restart..."
|
||||
)
|
||||
_run_systemctl(
|
||||
["reset-failed", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(
|
||||
["start", svc],
|
||||
system=system,
|
||||
check=False,
|
||||
timeout=90,
|
||||
)
|
||||
try:
|
||||
_run_systemctl(["restart", svc], system=system, check=True, timeout=90)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return
|
||||
raise
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
return
|
||||
|
||||
@@ -2118,8 +2323,14 @@ def systemd_restart(system: bool = False):
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
_run_systemctl(["reload-or-restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
print(f"✓ {_service_scope_label(system).capitalize()} service restarted")
|
||||
try:
|
||||
_run_systemctl(["restart", get_service_name()], system=system, check=True, timeout=90)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if _systemd_error_indicates_start_limit(exc) or _systemd_service_is_start_limited(system=system):
|
||||
_print_systemd_start_limit_wait(system=system)
|
||||
return
|
||||
raise
|
||||
_wait_for_systemd_service_restart(system=system, previous_pid=pid)
|
||||
|
||||
|
||||
|
||||
@@ -2191,6 +2402,10 @@ def systemd_status(deep: bool = False, system: bool = False, full: bool = False)
|
||||
result_code = unit_props.get("Result", "")
|
||||
if active_state == "activating" and sub_state == "auto-restart":
|
||||
print(" ⏳ Restart pending: systemd is waiting to relaunch the gateway")
|
||||
elif _systemd_unit_is_start_limited(unit_props):
|
||||
print(" ⏳ Restart pending: systemd is temporarily rate-limiting starts")
|
||||
print(f" Run after the start-limit window expires: {'sudo ' if system else ''}hermes gateway restart{scope_flag}")
|
||||
print(f" Or clear it manually: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()}")
|
||||
elif active_state == "failed" and exec_main_status == str(GATEWAY_SERVICE_RESTART_EXIT_CODE):
|
||||
print(" ⚠ Planned restart is stuck in systemd failed state (exit 75)")
|
||||
print(f" Run: systemctl {'--user ' if not system else ''}reset-failed {get_service_name()} && {'sudo ' if system else ''}hermes gateway start{scope_flag}")
|
||||
@@ -2555,6 +2770,42 @@ def launchd_status(deep: bool = False):
|
||||
# Gateway Runner
|
||||
# =============================================================================
|
||||
|
||||
def _truthy_env(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _is_official_docker_checkout() -> bool:
|
||||
return (
|
||||
str(PROJECT_ROOT) == "/opt/hermes"
|
||||
and (PROJECT_ROOT / "docker" / "entrypoint.sh").is_file()
|
||||
)
|
||||
|
||||
|
||||
def _guard_official_docker_root_gateway() -> None:
|
||||
"""Refuse gateway startup when the official Docker privilege drop was bypassed."""
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return
|
||||
if _truthy_env(os.getenv("HERMES_ALLOW_ROOT_GATEWAY")):
|
||||
return
|
||||
if not _is_official_docker_checkout():
|
||||
return
|
||||
|
||||
print_error(
|
||||
"Refusing to run the Hermes gateway as root inside the official Docker image."
|
||||
)
|
||||
print(
|
||||
" The image entrypoint normally drops privileges to the 'hermes' user. "
|
||||
"If you override entrypoint in Docker Compose, include "
|
||||
"/opt/hermes/docker/entrypoint.sh before the Hermes command."
|
||||
)
|
||||
print(
|
||||
" Running the gateway as root can leave root-owned files in "
|
||||
"$HERMES_HOME and break later non-root dashboard/gateway runs."
|
||||
)
|
||||
print(" Set HERMES_ALLOW_ROOT_GATEWAY=1 only if you intentionally accept this risk.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
@@ -2565,6 +2816,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Refresh the systemd unit definition on every boot so that restart
|
||||
@@ -4115,7 +4367,9 @@ def gateway_setup():
|
||||
print_success("Gateway service is installed and running.")
|
||||
elif service_installed:
|
||||
print_warning("Gateway service is installed but not running.")
|
||||
if prompt_yes_no(" Start it now?", True):
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start it now?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4125,6 +4379,12 @@ def gateway_setup():
|
||||
print_error(" Failed to start — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have caught
|
||||
# this, but handle the race/edge case gracefully instead of
|
||||
# letting the exception escape the wizard.
|
||||
print_error(f" Failed to start: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Failed to start: {e}")
|
||||
else:
|
||||
@@ -4174,7 +4434,9 @@ def gateway_setup():
|
||||
service_running = _is_service_running()
|
||||
|
||||
if service_running:
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_restart()
|
||||
@@ -4187,10 +4449,15 @@ def gateway_setup():
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
if supports_systemd_services() and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd_services():
|
||||
systemd_start()
|
||||
@@ -4200,6 +4467,9 @@ def gateway_setup():
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
else:
|
||||
@@ -4273,6 +4543,14 @@ def gateway_command(args):
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# The direct ``hermes gateway install|uninstall|start|stop|restart``
|
||||
# path lands here when the user typed a system-scope action without
|
||||
# sudo. Same exit code as before — just gives the wizard a way to
|
||||
# intercept the same condition with friendlier guidance before the
|
||||
# error is raised.
|
||||
print(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _gateway_command_inner(args):
|
||||
@@ -4597,6 +4875,9 @@ def _gateway_command_inner(args):
|
||||
# Show other profiles' gateway status for multi-profile awareness
|
||||
_print_other_profiles_gateway_status()
|
||||
|
||||
elif subcmd == "list":
|
||||
_gateway_list()
|
||||
|
||||
elif subcmd == "migrate-legacy":
|
||||
# Stop, disable, and remove legacy Hermes gateway unit files from
|
||||
# pre-rename installs (e.g. hermes.service). Profile units and
|
||||
|
||||
+79
-21
@@ -47,6 +47,14 @@ DEFAULT_MAX_TURNS = 20
|
||||
DEFAULT_JUDGE_TIMEOUT = 30.0
|
||||
# Cap how much of the last response + recent messages we send to the judge.
|
||||
_JUDGE_RESPONSE_SNIPPET_CHARS = 4000
|
||||
# After this many consecutive judge *parse* failures (empty output / non-JSON),
|
||||
# the loop auto-pauses and points the user at the goal_judge config. API /
|
||||
# transport errors do NOT count toward this — those are transient. This guards
|
||||
# against small models (e.g. deepseek-v4-flash) that cannot follow the strict
|
||||
# JSON reply contract; without it the loop runs until the turn budget is
|
||||
# exhausted with every reply shaped like `judge returned empty response` or
|
||||
# `judge reply was not JSON`.
|
||||
DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES = 3
|
||||
|
||||
|
||||
CONTINUATION_PROMPT_TEMPLATE = (
|
||||
@@ -99,6 +107,7 @@ class GoalState:
|
||||
last_verdict: Optional[str] = None # "done" | "continue" | "skipped"
|
||||
last_reason: Optional[str] = None
|
||||
paused_reason: Optional[str] = None # why we auto-paused (budget, etc.)
|
||||
consecutive_parse_failures: int = 0 # judge-output parse failures in a row
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(asdict(self), ensure_ascii=False)
|
||||
@@ -116,6 +125,7 @@ class GoalState:
|
||||
last_verdict=data.get("last_verdict"),
|
||||
last_reason=data.get("last_reason"),
|
||||
paused_reason=data.get("paused_reason"),
|
||||
consecutive_parse_failures=int(data.get("consecutive_parse_failures", 0) or 0),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,13 +230,17 @@ def _truncate(text: str, limit: int) -> str:
|
||||
_JSON_OBJECT_RE = re.compile(r"\{.*?\}", re.DOTALL)
|
||||
|
||||
|
||||
def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
"""Parse the judge's reply. Fail-open to ``(False, "<reason>")``.
|
||||
def _parse_judge_response(raw: str) -> Tuple[bool, str, bool]:
|
||||
"""Parse the judge's reply. Fail-open to ``(False, "<reason>", parse_failed)``.
|
||||
|
||||
Returns ``(done, reason)``.
|
||||
Returns ``(done, reason, parse_failed)``. ``parse_failed`` is True when the
|
||||
judge returned output that couldn't be interpreted as the expected JSON
|
||||
verdict (empty body, prose, malformed JSON). Callers use that flag to
|
||||
auto-pause after N consecutive parse failures so a weak judge model
|
||||
doesn't silently burn the turn budget.
|
||||
"""
|
||||
if not raw:
|
||||
return False, "judge returned empty response"
|
||||
return False, "judge returned empty response", True
|
||||
|
||||
text = raw.strip()
|
||||
|
||||
@@ -252,7 +266,7 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
data = None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}"
|
||||
return False, f"judge reply was not JSON: {_truncate(raw, 200)!r}", True
|
||||
|
||||
done_val = data.get("done")
|
||||
if isinstance(done_val, str):
|
||||
@@ -262,7 +276,7 @@ def _parse_judge_response(raw: str) -> Tuple[bool, str]:
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
if not reason:
|
||||
reason = "no reason provided"
|
||||
return done, reason
|
||||
return done, reason, False
|
||||
|
||||
|
||||
def judge_goal(
|
||||
@@ -270,36 +284,42 @@ def judge_goal(
|
||||
last_response: str,
|
||||
*,
|
||||
timeout: float = DEFAULT_JUDGE_TIMEOUT,
|
||||
) -> Tuple[str, str]:
|
||||
) -> Tuple[str, str, bool]:
|
||||
"""Ask the auxiliary model whether the goal is satisfied.
|
||||
|
||||
Returns ``(verdict, reason)`` where verdict is ``"done"``, ``"continue"``,
|
||||
or ``"skipped"`` (when the judge couldn't be reached).
|
||||
Returns ``(verdict, reason, parse_failed)`` where verdict is ``"done"``,
|
||||
``"continue"``, or ``"skipped"`` (when the judge couldn't be reached).
|
||||
|
||||
This is deliberately fail-open: any error returns ``("continue", "...")``
|
||||
so a broken judge doesn't wedge progress — the turn budget is the
|
||||
backstop.
|
||||
``parse_failed`` is True only when the judge call succeeded but its output
|
||||
was unusable (empty or non-JSON). API/transport errors return False — they
|
||||
are transient and should fail-open silently. Callers use this flag to
|
||||
auto-pause after N consecutive parse failures (see
|
||||
``DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES``).
|
||||
|
||||
This is deliberately fail-open: any error returns ``("continue", "...", False)``
|
||||
so a broken judge doesn't wedge progress — the turn budget and the
|
||||
consecutive-parse-failures auto-pause are the backstops.
|
||||
"""
|
||||
if not goal.strip():
|
||||
return "skipped", "empty goal"
|
||||
return "skipped", "empty goal", False
|
||||
if not last_response.strip():
|
||||
# No substantive reply this turn — almost certainly not done yet.
|
||||
return "continue", "empty response (nothing to evaluate)"
|
||||
return "continue", "empty response (nothing to evaluate)", False
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: auxiliary client import failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
return "continue", "auxiliary client unavailable", False
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("goal_judge")
|
||||
except Exception as exc:
|
||||
logger.debug("goal judge: get_text_auxiliary_client failed: %s", exc)
|
||||
return "continue", "auxiliary client unavailable"
|
||||
return "continue", "auxiliary client unavailable", False
|
||||
|
||||
if client is None or not model:
|
||||
return "continue", "no auxiliary client configured"
|
||||
return "continue", "no auxiliary client configured", False
|
||||
|
||||
prompt = JUDGE_USER_PROMPT_TEMPLATE.format(
|
||||
goal=_truncate(goal, 2000),
|
||||
@@ -319,17 +339,17 @@ def judge_goal(
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info("goal judge: API call failed (%s) — falling through to continue", exc)
|
||||
return "continue", f"judge error: {type(exc).__name__}"
|
||||
return "continue", f"judge error: {type(exc).__name__}", False
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
done, reason = _parse_judge_response(raw)
|
||||
done, reason, parse_failed = _parse_judge_response(raw)
|
||||
verdict = "done" if done else "continue"
|
||||
logger.info("goal judge: verdict=%s reason=%s", verdict, _truncate(reason, 120))
|
||||
return verdict, reason
|
||||
return verdict, reason, parse_failed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@@ -473,10 +493,18 @@ class GoalManager:
|
||||
state.turns_used += 1
|
||||
state.last_turn_at = time.time()
|
||||
|
||||
verdict, reason = judge_goal(state.goal, last_response)
|
||||
verdict, reason, parse_failed = judge_goal(state.goal, last_response)
|
||||
state.last_verdict = verdict
|
||||
state.last_reason = reason
|
||||
|
||||
# Track consecutive judge parse failures. Reset on any usable reply,
|
||||
# including API / transport errors (parse_failed=False) so a flaky
|
||||
# network doesn't trip the auto-pause meant for bad judge models.
|
||||
if parse_failed:
|
||||
state.consecutive_parse_failures += 1
|
||||
else:
|
||||
state.consecutive_parse_failures = 0
|
||||
|
||||
if verdict == "done":
|
||||
state.status = "done"
|
||||
save_goal(self.session_id, state)
|
||||
@@ -489,6 +517,36 @@ class GoalManager:
|
||||
"message": f"✓ Goal achieved: {reason}",
|
||||
}
|
||||
|
||||
# Auto-pause when the judge model can't produce the expected JSON
|
||||
# verdict N turns in a row. Points the user at the goal_judge config
|
||||
# so they can route this side task to a model that follows the
|
||||
# contract (e.g. google/gemini-3-flash-preview). Without this guard,
|
||||
# weak judge models burn the entire turn budget returning prose or
|
||||
# empty strings.
|
||||
if state.consecutive_parse_failures >= DEFAULT_MAX_CONSECUTIVE_PARSE_FAILURES:
|
||||
state.status = "paused"
|
||||
state.paused_reason = (
|
||||
f"judge model returned unparseable output {state.consecutive_parse_failures} turns in a row"
|
||||
)
|
||||
save_goal(self.session_id, state)
|
||||
return {
|
||||
"status": "paused",
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"verdict": "continue",
|
||||
"reason": reason,
|
||||
"message": (
|
||||
f"⏸ Goal paused — the judge model ({state.consecutive_parse_failures} turns) "
|
||||
"isn't returning the required JSON verdict. Route the judge to a stricter "
|
||||
"model in ~/.hermes/config.yaml:\n"
|
||||
" auxiliary:\n"
|
||||
" goal_judge:\n"
|
||||
" provider: openrouter\n"
|
||||
" model: google/gemini-3-flash-preview\n"
|
||||
"Then /goal resume to continue."
|
||||
),
|
||||
}
|
||||
|
||||
if state.turns_used >= state.max_turns:
|
||||
state.status = "paused"
|
||||
state.paused_reason = f"turn budget exhausted ({state.turns_used}/{state.max_turns})"
|
||||
|
||||
+150
-2
@@ -70,6 +70,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
|
||||
"completed_at": t.completed_at,
|
||||
"result": t.result,
|
||||
"skills": list(t.skills) if t.skills else [],
|
||||
"max_retries": t.max_retries,
|
||||
}
|
||||
|
||||
|
||||
@@ -284,6 +285,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
"(repeatable). Appended to the built-in "
|
||||
"kanban-worker skill. Example: "
|
||||
"--skill translation --skill github-code-review")
|
||||
p_create.add_argument("--max-retries", type=int, default=None,
|
||||
metavar="N",
|
||||
help="Per-task override for the consecutive-failure "
|
||||
"circuit breaker. Trip on the Nth failure — "
|
||||
"e.g. --max-retries 1 blocks on the first "
|
||||
"failure (no retries), --max-retries 3 allows "
|
||||
"two retries. Omit to use the dispatcher's "
|
||||
"kanban.failure_limit config "
|
||||
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
|
||||
p_create.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
|
||||
# --- list ---
|
||||
@@ -443,8 +453,8 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
help="Cap number of spawns this pass")
|
||||
p_disp.add_argument("--failure-limit", type=int,
|
||||
default=kb.DEFAULT_SPAWN_FAILURE_LIMIT,
|
||||
help=f"Auto-block a task after this many consecutive spawn failures "
|
||||
f"(default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
|
||||
help=f"Auto-block a task after this many consecutive non-success attempts "
|
||||
f"(spawn_failed, timed_out, or crashed; default: {kb.DEFAULT_SPAWN_FAILURE_LIMIT})")
|
||||
p_disp.add_argument("--json", action="store_true")
|
||||
|
||||
# --- daemon (deprecated) ---
|
||||
@@ -560,6 +570,42 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
)
|
||||
p_ctx.add_argument("task_id")
|
||||
|
||||
# --- specify --- (triage → todo via auxiliary LLM)
|
||||
p_specify = sub.add_parser(
|
||||
"specify",
|
||||
help="Flesh out a triage-column task into a concrete spec "
|
||||
"(title + body) and promote it to todo. Uses the auxiliary "
|
||||
"LLM configured under auxiliary.triage_specifier.",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"task_id",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Task id to specify (required unless --all is given)",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--all",
|
||||
dest="all_triage",
|
||||
action="store_true",
|
||||
help="Specify every task currently in the triage column",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--tenant",
|
||||
default=None,
|
||||
help="When used with --all, restrict the sweep to this tenant",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--author",
|
||||
default=None,
|
||||
help="Author name recorded on the audit comment "
|
||||
"(default: $HERMES_PROFILE or 'specifier')",
|
||||
)
|
||||
p_specify.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit one JSON object per task on stdout",
|
||||
)
|
||||
|
||||
# --- gc ---
|
||||
p_gc = sub.add_parser(
|
||||
"gc", help="Garbage-collect archived-task workspaces, old events, and old logs",
|
||||
@@ -674,6 +720,7 @@ def kanban_command(args: argparse.Namespace) -> int:
|
||||
"notify-list": _cmd_notify_list,
|
||||
"notify-unsubscribe": _cmd_notify_unsubscribe,
|
||||
"context": _cmd_context,
|
||||
"specify": _cmd_specify,
|
||||
"gc": _cmd_gc,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
@@ -982,6 +1029,14 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
except ValueError as exc:
|
||||
print(f"kanban: --max-runtime: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
max_retries = getattr(args, "max_retries", None)
|
||||
if max_retries is not None and max_retries < 1:
|
||||
print(
|
||||
f"kanban: --max-retries must be >= 1 (got {max_retries}); "
|
||||
"use 1 to trip on the first failure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
with kb.connect() as conn:
|
||||
task_id = kb.create_task(
|
||||
conn,
|
||||
@@ -998,6 +1053,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
idempotency_key=getattr(args, "idempotency_key", None),
|
||||
max_runtime_seconds=max_runtime,
|
||||
skills=getattr(args, "skills", None) or None,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
task = kb.get_task(conn, task_id)
|
||||
if getattr(args, "json", False):
|
||||
@@ -1125,6 +1181,23 @@ def _cmd_show(args: argparse.Namespace) -> int:
|
||||
(f" @ {task.workspace_path}" if task.workspace_path else ""))
|
||||
if task.skills:
|
||||
print(f" skills: {', '.join(task.skills)}")
|
||||
# Effective retry threshold. Show the per-task override if set,
|
||||
# otherwise the dispatcher's resolved value from config (or the
|
||||
# default if config doesn't set it either). Helps operators see
|
||||
# why a task auto-blocked earlier/later than they expected.
|
||||
if task.max_retries is not None:
|
||||
print(f" max-retries: {task.max_retries} (task)")
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
cfg_val = (cfg.get("kanban", {}) or {}).get("failure_limit")
|
||||
except Exception:
|
||||
cfg_val = None
|
||||
if cfg_val is not None and int(cfg_val) != kb.DEFAULT_FAILURE_LIMIT:
|
||||
print(f" max-retries: {int(cfg_val)} (config kanban.failure_limit)")
|
||||
else:
|
||||
print(f" max-retries: {kb.DEFAULT_FAILURE_LIMIT} (default)")
|
||||
print(f" created: {_fmt_ts(task.created_at)} by {task.created_by or '-'}")
|
||||
|
||||
# Diagnostics section — surface active distress signals at the top
|
||||
@@ -1657,6 +1730,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
||||
" kanban:\n"
|
||||
" dispatch_in_gateway: true # default\n"
|
||||
" dispatch_interval_seconds: 60\n"
|
||||
" failure_limit: 2 # consecutive non-success attempts before auto-block\n"
|
||||
"\n"
|
||||
"Running both the gateway AND this standalone daemon will\n"
|
||||
"race for claims. If you truly need the old standalone\n"
|
||||
@@ -1943,6 +2017,80 @@ def _cmd_context(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_specify(args: argparse.Namespace) -> int:
|
||||
"""Flesh out a triage task (or all of them) via auxiliary LLM,
|
||||
then promote to todo. Thin wrapper over ``kanban_specify``."""
|
||||
from hermes_cli import kanban_specify as spec
|
||||
|
||||
all_flag = bool(getattr(args, "all_triage", False))
|
||||
tenant = getattr(args, "tenant", None)
|
||||
author = getattr(args, "author", None) or _profile_author()
|
||||
want_json = bool(getattr(args, "json", False))
|
||||
|
||||
if args.task_id and all_flag:
|
||||
print(
|
||||
"kanban: pass either a task id OR --all, not both",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if all_flag:
|
||||
ids = spec.list_triage_ids(tenant=tenant)
|
||||
if not ids:
|
||||
msg = (
|
||||
"No triage tasks"
|
||||
+ (f" for tenant {tenant!r}" if tenant else "")
|
||||
+ "."
|
||||
)
|
||||
if want_json:
|
||||
print(json.dumps({"specified": 0, "total": 0}))
|
||||
else:
|
||||
print(msg)
|
||||
return 0
|
||||
elif args.task_id:
|
||||
ids = [args.task_id]
|
||||
else:
|
||||
print(
|
||||
"kanban: specify requires a task id or --all",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for tid in ids:
|
||||
outcome = spec.specify_task(tid, author=author)
|
||||
if outcome.ok:
|
||||
ok_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
if want_json:
|
||||
print(json.dumps({
|
||||
"task_id": outcome.task_id,
|
||||
"ok": outcome.ok,
|
||||
"reason": outcome.reason,
|
||||
"new_title": outcome.new_title,
|
||||
}))
|
||||
else:
|
||||
if outcome.ok:
|
||||
title_suffix = (
|
||||
f" — retitled: {outcome.new_title!r}"
|
||||
if outcome.new_title
|
||||
else ""
|
||||
)
|
||||
print(f"Specified {outcome.task_id} → todo{title_suffix}")
|
||||
else:
|
||||
print(
|
||||
f"kanban: specify {outcome.task_id}: {outcome.reason}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not all_flag:
|
||||
return 0 if ok_count == 1 else 1
|
||||
# --all: succeed if at least one promotion landed; exit 1 only when
|
||||
# every candidate failed (honest signal for scripts).
|
||||
return 0 if (ok_count > 0 or not ids) else 1
|
||||
|
||||
|
||||
def _cmd_gc(args: argparse.Namespace) -> int:
|
||||
"""Remove scratch workspaces of archived tasks, prune old events, and
|
||||
delete old worker logs."""
|
||||
|
||||
+442
-58
@@ -595,6 +595,14 @@ class Task:
|
||||
# JSON array of skill names. None = use only the defaults; empty
|
||||
# list = explicitly no extra skills.
|
||||
skills: Optional[list] = None
|
||||
# Per-task override for the consecutive-failure circuit breaker.
|
||||
# The value is the failure count at which the breaker trips — e.g.
|
||||
# ``max_retries=1`` blocks on the first failure (zero retries),
|
||||
# ``max_retries=3`` blocks on the third (two retries allowed).
|
||||
# ``None`` (the common case) falls through to the dispatcher-level
|
||||
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
|
||||
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
|
||||
max_retries: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: sqlite3.Row) -> "Task":
|
||||
@@ -656,6 +664,9 @@ class Task:
|
||||
row["current_step_key"] if "current_step_key" in keys else None
|
||||
),
|
||||
skills=skills_value,
|
||||
max_retries=(
|
||||
row["max_retries"] if "max_retries" in keys else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -776,7 +787,13 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
-- Force-loaded skills for the worker on this task, stored as JSON.
|
||||
-- Appended to the dispatcher's built-in `--skills kanban-worker`.
|
||||
-- NULL or empty array = no extras.
|
||||
skills TEXT
|
||||
skills TEXT,
|
||||
-- Per-task override for the consecutive-failure circuit breaker.
|
||||
-- The value is the failure count at which the breaker trips — e.g.
|
||||
-- ``max_retries=1`` blocks on the first failure. NULL (the common
|
||||
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
|
||||
-- config and then ``DEFAULT_FAILURE_LIMIT``.
|
||||
max_retries INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_links (
|
||||
@@ -1008,6 +1025,14 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
# for existing rows.
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN skills TEXT")
|
||||
|
||||
if "max_retries" not in cols:
|
||||
# Per-task override for the consecutive-failure circuit breaker.
|
||||
# NULL = fall through to the dispatcher-level ``kanban.failure_limit``
|
||||
# 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")
|
||||
|
||||
# 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)")}
|
||||
@@ -1163,6 +1188,7 @@ def create_task(
|
||||
idempotency_key: Optional[str] = None,
|
||||
max_runtime_seconds: Optional[int] = None,
|
||||
skills: Optional[Iterable[str]] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Create a new task and optionally link it under parent tasks.
|
||||
|
||||
@@ -1276,8 +1302,9 @@ def create_task(
|
||||
INSERT INTO tasks (
|
||||
id, title, body, assignee, status, priority,
|
||||
created_by, created_at, workspace_kind, workspace_path,
|
||||
tenant, idempotency_key, max_runtime_seconds, skills
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
tenant, idempotency_key, max_runtime_seconds, skills,
|
||||
max_retries
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
@@ -1294,6 +1321,7 @@ def create_task(
|
||||
idempotency_key,
|
||||
int(max_runtime_seconds) if max_runtime_seconds else None,
|
||||
json.dumps(skills_list) if skills_list is not None else None,
|
||||
int(max_retries) if max_retries is not None else None,
|
||||
),
|
||||
)
|
||||
for pid in parents:
|
||||
@@ -1380,7 +1408,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
|
||||
profile = _canonical_assignee(profile)
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)
|
||||
"SELECT status, claim_lock, assignee FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
@@ -1389,7 +1417,17 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
|
||||
f"cannot reassign {task_id}: currently running (claimed). "
|
||||
"Wait for completion or reclaim the stale lock first."
|
||||
)
|
||||
conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id))
|
||||
if row["assignee"] != profile:
|
||||
# The retry guard is scoped to the task/profile combination. A
|
||||
# human reassigning the task is an explicit recovery action, so the
|
||||
# new profile should not inherit the previous profile's streak.
|
||||
conn.execute(
|
||||
"UPDATE tasks SET assignee = ?, consecutive_failures = 0, "
|
||||
"last_failure_error = NULL WHERE id = ?",
|
||||
(profile, task_id),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE tasks SET assignee = ? WHERE id = ?", (profile, task_id))
|
||||
_append_event(conn, task_id, "assigned", {"assignee": profile})
|
||||
return True
|
||||
|
||||
@@ -1859,34 +1897,47 @@ def heartbeat_claim(
|
||||
return False
|
||||
|
||||
|
||||
def release_stale_claims(conn: sqlite3.Connection) -> int:
|
||||
def release_stale_claims(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
signal_fn=None,
|
||||
) -> int:
|
||||
"""Reset any ``running`` task whose claim has expired.
|
||||
|
||||
Returns the number of stale claims reclaimed. Safe to call often.
|
||||
"""
|
||||
now = int(time.time())
|
||||
reclaimed = 0
|
||||
with write_txn(conn):
|
||||
stale = conn.execute(
|
||||
"SELECT id, claim_lock FROM tasks "
|
||||
"WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for row in stale:
|
||||
conn.execute(
|
||||
stale = conn.execute(
|
||||
"SELECT id, claim_lock, worker_pid FROM tasks "
|
||||
"WHERE status = 'running' AND claim_expires IS NOT NULL AND claim_expires < ?",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for row in stale:
|
||||
termination = _terminate_reclaimed_worker(
|
||||
row["worker_pid"], row["claim_lock"], signal_fn=signal_fn,
|
||||
)
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status = 'running'",
|
||||
(row["id"],),
|
||||
"WHERE id = ? AND status = 'running' AND claim_lock IS ? "
|
||||
"AND claim_expires IS NOT NULL AND claim_expires < ?",
|
||||
(row["id"], row["claim_lock"], now),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
continue
|
||||
run_id = _end_run(
|
||||
conn, row["id"],
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
error=f"stale_lock={row['claim_lock']}",
|
||||
metadata=termination,
|
||||
)
|
||||
payload = {"stale_lock": row["claim_lock"]}
|
||||
payload.update(termination)
|
||||
_append_event(
|
||||
conn, row["id"], "reclaimed",
|
||||
{"stale_lock": row["claim_lock"]},
|
||||
payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
reclaimed += 1
|
||||
@@ -1898,6 +1949,7 @@ def reclaim_task(
|
||||
task_id: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
signal_fn=None,
|
||||
) -> bool:
|
||||
"""Operator-driven reclaim: release the claim and reset to ``ready``.
|
||||
|
||||
@@ -1910,24 +1962,29 @@ def reclaim_task(
|
||||
Returns True if a reclaim happened, False if the task isn't in a
|
||||
reclaimable state (not running, or doesn't exist).
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
termination = _terminate_reclaimed_worker(
|
||||
row["worker_pid"], prev_lock, signal_fn=signal_fn,
|
||||
)
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
prev_pid = row["worker_pid"]
|
||||
conn.execute(
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked')",
|
||||
(task_id,),
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
|
||||
"AND claim_lock IS ?",
|
||||
(task_id, prev_lock),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
return False
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
@@ -1935,15 +1992,17 @@ def reclaim_task(
|
||||
f"manual_reclaim: {reason}" if reason
|
||||
else f"manual_reclaim lock={prev_lock}"
|
||||
),
|
||||
metadata=termination,
|
||||
)
|
||||
payload = {
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
}
|
||||
payload.update(termination)
|
||||
_append_event(
|
||||
conn, task_id, "reclaimed",
|
||||
{
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
"prev_pid": prev_pid,
|
||||
},
|
||||
payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Operator intervention — they've looked at the task, so the
|
||||
@@ -2444,6 +2503,91 @@ def unblock_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def specify_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Flesh out a triage task and promote it to ``todo``.
|
||||
|
||||
Atomically updates ``title`` / ``body`` (when provided) and transitions
|
||||
``status: triage -> todo`` in a single write txn. Returns False when
|
||||
the task is missing or not in the ``triage`` column — callers should
|
||||
surface that as "nothing to specify" rather than an error.
|
||||
|
||||
``todo`` (not ``ready``) is the correct landing column: ``recompute_ready``
|
||||
promotes parent-free / parent-done todos to ``ready`` on the next
|
||||
dispatcher tick, which keeps the normal parent-gating behaviour intact
|
||||
for specified tasks that happen to have open parents.
|
||||
|
||||
``author`` is recorded on an audit comment only when at least one of
|
||||
``title`` / ``body`` actually changed — avoids noisy comment spam for
|
||||
status-only promotions.
|
||||
"""
|
||||
if title is not None and not title.strip():
|
||||
raise ValueError("title cannot be blank")
|
||||
with write_txn(conn):
|
||||
existing = conn.execute(
|
||||
"SELECT title, body FROM tasks WHERE id = ? AND status = 'triage'",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
return False
|
||||
sets: list[str] = ["status = 'todo'"]
|
||||
params: list[Any] = []
|
||||
changed_fields: list[str] = []
|
||||
if title is not None and title.strip() != (existing["title"] or ""):
|
||||
sets.append("title = ?")
|
||||
params.append(title.strip())
|
||||
changed_fields.append("title")
|
||||
if body is not None and (body or "") != (existing["body"] or ""):
|
||||
sets.append("body = ?")
|
||||
params.append(body)
|
||||
changed_fields.append("body")
|
||||
params.append(task_id)
|
||||
cur = conn.execute(
|
||||
f"UPDATE tasks SET {', '.join(sets)} "
|
||||
f"WHERE id = ? AND status = 'triage'",
|
||||
tuple(params),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
return False
|
||||
if changed_fields and author and author.strip():
|
||||
# Inline INSERT (rather than ``add_comment``) because we're
|
||||
# already inside this function's write_txn — nested BEGIN
|
||||
# IMMEDIATE would raise OperationalError. We also skip the
|
||||
# 'commented' event that ``add_comment`` emits, since the
|
||||
# 'specified' event below already records the change.
|
||||
conn.execute(
|
||||
"INSERT INTO task_comments (task_id, author, body, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
author.strip(),
|
||||
"Specified — updated "
|
||||
+ ", ".join(changed_fields)
|
||||
+ " and promoted to todo.",
|
||||
int(time.time()),
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn,
|
||||
task_id,
|
||||
"specified",
|
||||
{"changed_fields": changed_fields} if changed_fields else None,
|
||||
)
|
||||
# Outside the write_txn above, so we don't nest BEGIN IMMEDIATE — the
|
||||
# ready-promotion pass opens its own IMMEDIATE txn. This runs the same
|
||||
# logic the dispatcher would on its next tick, so a specified task
|
||||
# with no open parents flips straight to 'ready' here instead of
|
||||
# idling in 'todo' until the next sweep.
|
||||
recompute_ready(conn)
|
||||
return True
|
||||
|
||||
|
||||
def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
@@ -2548,11 +2692,11 @@ def set_workspace_path(
|
||||
# Dispatcher (one-shot pass)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# After this many consecutive `spawn_failed` events on a task, the dispatcher
|
||||
# stops retrying and parks the task in ``blocked`` with a reason so a human
|
||||
# can investigate. Prevents the dispatcher from thrashing forever on a task
|
||||
# whose profile doesn't exist, whose workspace is unmountable, etc.
|
||||
DEFAULT_FAILURE_LIMIT = 5
|
||||
# After this many consecutive non-success attempts on a task/profile, the
|
||||
# dispatcher stops retrying and parks the task in ``blocked`` with a reason so
|
||||
# a human can investigate. Prevents retry storms when a worker repeatedly times
|
||||
# out, crashes, or cannot spawn.
|
||||
DEFAULT_FAILURE_LIMIT = 2
|
||||
# Legacy alias — callers / tests still reference the old name.
|
||||
DEFAULT_SPAWN_FAILURE_LIMIT = DEFAULT_FAILURE_LIMIT
|
||||
|
||||
@@ -2587,6 +2731,77 @@ class DispatchResult:
|
||||
"""Task ids whose workers exceeded ``max_runtime_seconds``."""
|
||||
|
||||
|
||||
# Bounded registry of recently-reaped worker child exits, populated by the
|
||||
# reap loop at the top of ``dispatch_once`` and consulted by
|
||||
# ``detect_crashed_workers`` to classify a dead-pid task.
|
||||
#
|
||||
# Entry: ``pid -> (raw_wait_status, reaped_at_epoch)``. We keep raw status
|
||||
# so both ``os.WIFEXITED`` / ``os.WEXITSTATUS`` and ``os.WIFSIGNALED`` can
|
||||
# be consulted. Entries are trimmed by age (and total size cap as a
|
||||
# belt-and-braces against unbounded growth on exotic platforms).
|
||||
_RECENT_WORKER_EXIT_TTL_SECONDS = 600
|
||||
_RECENT_WORKER_EXITS_MAX = 4096
|
||||
_recent_worker_exits: "dict[int, tuple[int, float]]" = {}
|
||||
|
||||
|
||||
def _record_worker_exit(pid: int, raw_status: int) -> None:
|
||||
"""Record a reaped child's exit status for later classification.
|
||||
|
||||
Called from the reap loop in ``dispatch_once``. Safe to call many
|
||||
times; duplicate pids overwrite (pids can cycle, latest wins).
|
||||
"""
|
||||
if not pid or pid <= 0:
|
||||
return
|
||||
now = time.time()
|
||||
_recent_worker_exits[int(pid)] = (int(raw_status), now)
|
||||
# Age-based trim: drop entries older than the TTL.
|
||||
if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX // 2:
|
||||
cutoff = now - _RECENT_WORKER_EXIT_TTL_SECONDS
|
||||
for _pid in [p for p, (_s, t) in _recent_worker_exits.items() if t < cutoff]:
|
||||
_recent_worker_exits.pop(_pid, None)
|
||||
# Size cap as a final guard.
|
||||
if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX:
|
||||
# Drop oldest half.
|
||||
ordered = sorted(_recent_worker_exits.items(), key=lambda kv: kv[1][1])
|
||||
for _pid, _ in ordered[: len(ordered) // 2]:
|
||||
_recent_worker_exits.pop(_pid, None)
|
||||
|
||||
|
||||
def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
|
||||
"""Classify a recently-reaped worker by pid.
|
||||
|
||||
Returns ``(kind, code)`` where ``kind`` is one of:
|
||||
|
||||
* ``"clean_exit"`` — ``WIFEXITED`` with ``WEXITSTATUS == 0``. When the
|
||||
task is still ``running`` in the DB, this is a protocol violation
|
||||
(worker exited without calling ``kanban_complete`` / ``kanban_block``)
|
||||
and should be auto-blocked immediately — retrying will just loop.
|
||||
* ``"nonzero_exit"`` — ``WIFEXITED`` with non-zero status. Real error.
|
||||
* ``"signaled"`` — ``WIFSIGNALED`` (OOM killer, SIGKILL, etc). Real crash.
|
||||
* ``"unknown"`` — pid was not in the reap registry (either reaped by
|
||||
something else, or died between reap tick and liveness check). Fall
|
||||
back to existing crashed-counter behavior.
|
||||
|
||||
``code`` is the exit status (for ``clean_exit`` / ``nonzero_exit``) or
|
||||
the signal number (for ``signaled``), or ``None`` for ``unknown``.
|
||||
"""
|
||||
entry = _recent_worker_exits.get(int(pid))
|
||||
if entry is None:
|
||||
return ("unknown", None)
|
||||
raw, _ = entry
|
||||
try:
|
||||
if os.WIFEXITED(raw):
|
||||
code = os.WEXITSTATUS(raw)
|
||||
if code == 0:
|
||||
return ("clean_exit", 0)
|
||||
return ("nonzero_exit", code)
|
||||
if os.WIFSIGNALED(raw):
|
||||
return ("signaled", os.WTERMSIG(raw))
|
||||
except Exception:
|
||||
pass
|
||||
return ("unknown", None)
|
||||
|
||||
|
||||
def _pid_alive(pid: Optional[int]) -> bool:
|
||||
"""Return True if ``pid`` is still running on this host.
|
||||
|
||||
@@ -2652,6 +2867,59 @@ def _pid_alive(pid: Optional[int]) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _terminate_reclaimed_worker(
|
||||
pid: Optional[int],
|
||||
claim_lock: Optional[str],
|
||||
*,
|
||||
signal_fn=None,
|
||||
) -> dict[str, Any]:
|
||||
"""Best-effort host-local worker termination for reclaim paths."""
|
||||
import signal
|
||||
|
||||
info: dict[str, Any] = {
|
||||
"prev_pid": int(pid) if pid else None,
|
||||
"host_local": False,
|
||||
"termination_attempted": False,
|
||||
"terminated": False,
|
||||
"sigkill": False,
|
||||
}
|
||||
if not pid or pid <= 0 or not claim_lock:
|
||||
return info
|
||||
|
||||
host_prefix = f"{_claimer_id().split(':', 1)[0]}:"
|
||||
if not str(claim_lock).startswith(host_prefix):
|
||||
return info
|
||||
info["host_local"] = True
|
||||
|
||||
kill = signal_fn if signal_fn is not None else (
|
||||
os.kill if hasattr(os, "kill") else None
|
||||
)
|
||||
if kill is None:
|
||||
return info
|
||||
|
||||
info["termination_attempted"] = True
|
||||
try:
|
||||
kill(int(pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, OSError):
|
||||
return info
|
||||
|
||||
for _ in range(10):
|
||||
if not _pid_alive(pid):
|
||||
info["terminated"] = True
|
||||
return info
|
||||
time.sleep(0.5)
|
||||
|
||||
if _pid_alive(pid):
|
||||
try:
|
||||
kill(int(pid), signal.SIGKILL)
|
||||
info["sigkill"] = True
|
||||
except (ProcessLookupError, OSError):
|
||||
return info
|
||||
|
||||
info["terminated"] = not _pid_alive(pid)
|
||||
return info
|
||||
|
||||
|
||||
def heartbeat_worker(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
@@ -2840,12 +3108,22 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
|
||||
are meaningless here. The host-local check is enough because
|
||||
``_default_spawn`` always runs the worker on the same host as the
|
||||
dispatcher (the whole design is single-host).
|
||||
|
||||
When the reap registry shows the worker exited cleanly (rc=0) but
|
||||
the task was still ``running`` in the DB, treat it as a protocol
|
||||
violation (worker answered conversationally without calling
|
||||
``kanban_complete`` / ``kanban_block``) and trip the circuit breaker
|
||||
on the first occurrence — retrying a worker whose CLI keeps
|
||||
returning 0 without a terminal transition just loops forever.
|
||||
"""
|
||||
crashed: list[str] = []
|
||||
# Per-crash details collected inside the main txn, used after it
|
||||
# closes to run ``_record_task_failure`` (which needs its own
|
||||
# write_txn so can't nest).
|
||||
crash_details: list[tuple[str, int, str]] = [] # (task_id, pid, claimer)
|
||||
# write_txn so can't nest). ``protocol_violation`` flags the
|
||||
# clean-exit-but-still-running case so we can trip the breaker
|
||||
# immediately instead of incrementing by 1.
|
||||
crash_details: list[tuple[str, int, str, bool, str]] = []
|
||||
# (task_id, pid, claimer, protocol_violation, error_text)
|
||||
with write_txn(conn):
|
||||
rows = conn.execute(
|
||||
"SELECT id, worker_pid, claim_lock FROM tasks "
|
||||
@@ -2859,6 +3137,39 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
|
||||
continue
|
||||
if _pid_alive(row["worker_pid"]):
|
||||
continue
|
||||
|
||||
pid = int(row["worker_pid"])
|
||||
kind, code = _classify_worker_exit(pid)
|
||||
if kind == "clean_exit":
|
||||
# Worker subprocess returned 0 but its task is still
|
||||
# ``running`` in the DB — it exited without calling
|
||||
# ``kanban_complete`` / ``kanban_block``. Retrying won't
|
||||
# help.
|
||||
protocol_violation = True
|
||||
error_text = (
|
||||
"worker exited cleanly (rc=0) without calling "
|
||||
"kanban_complete or kanban_block — protocol violation"
|
||||
)
|
||||
event_kind = "protocol_violation"
|
||||
event_payload = {
|
||||
"pid": pid,
|
||||
"claimer": row["claim_lock"],
|
||||
"exit_code": code,
|
||||
}
|
||||
else:
|
||||
protocol_violation = False
|
||||
if kind == "nonzero_exit":
|
||||
error_text = f"pid {pid} exited with code {code}"
|
||||
elif kind == "signaled":
|
||||
error_text = f"pid {pid} killed by signal {code}"
|
||||
else:
|
||||
error_text = f"pid {pid} not alive"
|
||||
event_kind = "crashed"
|
||||
event_payload = {"pid": pid, "claimer": row["claim_lock"]}
|
||||
if code is not None and kind != "unknown":
|
||||
event_payload["exit_kind"] = kind
|
||||
event_payload["exit_code"] = code
|
||||
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
@@ -2869,34 +3180,47 @@ def detect_crashed_workers(conn: sqlite3.Connection) -> list[str]:
|
||||
run_id = _end_run(
|
||||
conn, row["id"],
|
||||
outcome="crashed", status="crashed",
|
||||
error=f"pid {int(row['worker_pid'])} not alive",
|
||||
metadata={
|
||||
"pid": int(row["worker_pid"]),
|
||||
"claimer": row["claim_lock"],
|
||||
},
|
||||
error=error_text,
|
||||
metadata=dict(event_payload),
|
||||
)
|
||||
_append_event(
|
||||
conn, row["id"], "crashed",
|
||||
{"pid": int(row["worker_pid"]), "claimer": row["claim_lock"]},
|
||||
conn, row["id"], event_kind,
|
||||
event_payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
crashed.append(row["id"])
|
||||
crash_details.append(
|
||||
(row["id"], int(row["worker_pid"]), row["claim_lock"])
|
||||
(row["id"], pid, row["claim_lock"],
|
||||
protocol_violation, error_text)
|
||||
)
|
||||
# Outside the main txn: increment the unified failure counter for
|
||||
# each crashed task. If the breaker trips, the task transitions
|
||||
# ready → blocked with a ``gave_up`` event on top of the ``crashed``
|
||||
# event we already emitted.
|
||||
for tid, pid, claimer in crash_details:
|
||||
_record_task_failure(
|
||||
#
|
||||
# Protocol-violation crashes force an immediate trip (failure_limit=1)
|
||||
# because clean-exit-without-transition is deterministic: the next
|
||||
# respawn will do exactly the same thing. Better to surface to a
|
||||
# human with a clear reason than to loop ``DEFAULT_FAILURE_LIMIT``
|
||||
# times first.
|
||||
auto_blocked: list[str] = []
|
||||
for tid, pid, claimer, protocol_violation, error_text in crash_details:
|
||||
tripped = _record_task_failure(
|
||||
conn, tid,
|
||||
error=f"pid {pid} not alive",
|
||||
error=error_text,
|
||||
outcome="crashed",
|
||||
failure_limit=(1 if protocol_violation else None),
|
||||
release_claim=False,
|
||||
end_run=False,
|
||||
event_payload_extra={"pid": pid, "claimer": claimer},
|
||||
)
|
||||
if tripped:
|
||||
auto_blocked.append(tid)
|
||||
# Stash auto-blocked ids on the function for the dispatch loop to pick up.
|
||||
# Keeps the public return type (``list[str]``) stable for direct callers
|
||||
# and tests that destructure the result; ``dispatch_once`` reads this
|
||||
# side-channel attribute to populate ``DispatchResult.auto_blocked``.
|
||||
detect_crashed_workers._last_auto_blocked = auto_blocked # type: ignore[attr-defined]
|
||||
return crashed
|
||||
|
||||
|
||||
@@ -2938,20 +3262,39 @@ def _record_task_failure(
|
||||
``event_payload_extra`` merges into the ``gave_up`` event payload
|
||||
when the breaker trips, so callers can include outcome-specific
|
||||
context (e.g. pid on crash, elapsed on timeout).
|
||||
|
||||
Resolution order for the effective threshold:
|
||||
1. per-task ``max_retries`` if set (nothing else overrides)
|
||||
2. caller-supplied ``failure_limit`` (gateway passes the config
|
||||
value from ``kanban.failure_limit``; tests pass fixed values)
|
||||
3. ``DEFAULT_FAILURE_LIMIT``
|
||||
"""
|
||||
if failure_limit is None:
|
||||
failure_limit = DEFAULT_FAILURE_LIMIT
|
||||
blocked = False
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT consecutive_failures, status FROM tasks WHERE id = ?", (task_id,),
|
||||
"SELECT consecutive_failures, status, max_retries "
|
||||
"FROM tasks WHERE id = ?", (task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
failures = int(row["consecutive_failures"]) + 1
|
||||
cur_status = row["status"]
|
||||
|
||||
if failures >= failure_limit:
|
||||
# Per-task override wins over both caller-supplied and default
|
||||
# thresholds. None (the common case) falls through.
|
||||
task_override = (
|
||||
row["max_retries"] if "max_retries" in row.keys() else None
|
||||
)
|
||||
if task_override is not None:
|
||||
effective_limit = int(task_override)
|
||||
limit_source = "task"
|
||||
else:
|
||||
effective_limit = int(failure_limit)
|
||||
limit_source = "dispatcher"
|
||||
|
||||
if failures >= effective_limit:
|
||||
# Trip the breaker.
|
||||
if release_claim:
|
||||
# Spawn path: still running, also clear claim state.
|
||||
@@ -2979,10 +3322,17 @@ def _record_task_failure(
|
||||
conn, task_id,
|
||||
outcome="gave_up", status="gave_up",
|
||||
error=error[:500],
|
||||
metadata={"failures": failures, "trigger_outcome": outcome},
|
||||
metadata={
|
||||
"failures": failures,
|
||||
"trigger_outcome": outcome,
|
||||
"effective_limit": effective_limit,
|
||||
"limit_source": limit_source,
|
||||
},
|
||||
)
|
||||
payload = {
|
||||
"failures": failures,
|
||||
"effective_limit": effective_limit,
|
||||
"limit_source": limit_source,
|
||||
"error": error[:500],
|
||||
"trigger_outcome": outcome,
|
||||
}
|
||||
@@ -3150,9 +3500,43 @@ def dispatch_once(
|
||||
``board`` pins workspace/log/db resolution for this tick to a specific
|
||||
board. When omitted, the current-board resolution chain is used.
|
||||
"""
|
||||
# Reap zombie children from previously spawned workers.
|
||||
# The gateway-embedded dispatcher is the parent of every worker spawned
|
||||
# via _default_spawn (start_new_session=True only detaches the
|
||||
# controlling tty, not the parent). Without an explicit waitpid, each
|
||||
# completed worker becomes a <defunct> entry that lingers until gateway
|
||||
# exit. WNOHANG keeps this non-blocking; ChildProcessError means no
|
||||
# children to reap. Bounded: at most one tick's worth of completions
|
||||
# can be in <defunct> at once.
|
||||
#
|
||||
# We also record the exit status keyed by pid, so
|
||||
# ``detect_crashed_workers`` can distinguish a worker that exited
|
||||
# cleanly without calling ``kanban_complete`` / ``kanban_block``
|
||||
# (protocol violation — auto-block) from a real crash (OOM killer,
|
||||
# SIGKILL, non-zero exit — existing counter behavior).
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_pid, _status = os.waitpid(-1, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
break
|
||||
if _pid == 0:
|
||||
break
|
||||
_record_worker_exit(_pid, _status)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = DispatchResult()
|
||||
result.reclaimed = release_stale_claims(conn)
|
||||
result.crashed = detect_crashed_workers(conn)
|
||||
# detect_crashed_workers stashes protocol-violation auto-blocks on
|
||||
# itself so the public list-return stays stable. Pull them into the
|
||||
# DispatchResult here so telemetry / tests see the trip.
|
||||
_crash_auto_blocked = getattr(
|
||||
detect_crashed_workers, "_last_auto_blocked", []
|
||||
)
|
||||
if _crash_auto_blocked:
|
||||
result.auto_blocked.extend(_crash_auto_blocked)
|
||||
result.timed_out = enforce_max_runtime(conn)
|
||||
result.promoted = recompute_ready(conn)
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Kanban triage specifier — flesh out a one-liner into a real spec.
|
||||
|
||||
Used by ``hermes kanban specify [task_id | --all]``. Takes a task that
|
||||
lives in the Triage column (a rough idea, typically only a title), calls
|
||||
the auxiliary LLM to produce:
|
||||
|
||||
* A tightened title (optional — only replaces if the model proposes a
|
||||
materially different one)
|
||||
* A concrete body: goal, proposed approach, acceptance criteria
|
||||
|
||||
and then flips the task ``triage -> todo`` via
|
||||
``kanban_db.specify_triage_task``. The dispatcher promotes it to
|
||||
``ready`` on its next tick (or immediately if there are no open parents).
|
||||
|
||||
Design notes
|
||||
------------
|
||||
|
||||
* This module intentionally mirrors ``hermes_cli/goals.py`` — same aux
|
||||
client pattern, same "empty config => skip, don't crash" tolerance.
|
||||
Keeps the surface area tiny and the failure modes predictable.
|
||||
|
||||
* The prompt is a short system + user pair. We ask for JSON with
|
||||
``{title, body}``; if parsing fails, we fall back to treating the
|
||||
whole response as the body and leave the title untouched. No
|
||||
retry loop — one shot, keep cost bounded.
|
||||
|
||||
* Structured output / JSON mode is not requested explicitly so the
|
||||
specifier works on providers that don't implement it. The parse
|
||||
is lenient (tolerates markdown code fences around the JSON).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SYSTEM_PROMPT = """You are the Kanban triage specifier for the Hermes Agent board.
|
||||
A user dropped a rough idea into the Triage column. Your job is to turn it
|
||||
into a concrete, actionable task spec that an autonomous worker can pick up
|
||||
and execute without further clarification.
|
||||
|
||||
Output a single JSON object with exactly two keys:
|
||||
|
||||
{
|
||||
"title": "<tightened task title, <= 80 chars, imperative voice>",
|
||||
"body": "<multi-line spec, see structure below>"
|
||||
}
|
||||
|
||||
The body MUST include these sections, each prefixed with a bold markdown
|
||||
heading, in this order:
|
||||
|
||||
**Goal** — one sentence, user-facing outcome.
|
||||
**Approach** — 2-5 bullets on how a worker should tackle it.
|
||||
**Acceptance criteria** — checklist of concrete, verifiable conditions.
|
||||
**Out of scope** — short list of things NOT to touch (omit if nothing
|
||||
obvious; never invent scope creep).
|
||||
|
||||
Rules:
|
||||
- Keep the tightened title close in meaning to the original idea — do
|
||||
NOT invent a different project.
|
||||
- If the original idea is already detailed, preserve its substance and
|
||||
just reformat into the sections above.
|
||||
- Never add invented requirements the user didn't hint at.
|
||||
- No preamble, no closing remarks, no code fences around the JSON.
|
||||
- Output only the JSON object and nothing else.
|
||||
"""
|
||||
|
||||
|
||||
_USER_TEMPLATE = """Task id: {task_id}
|
||||
Current title: {title}
|
||||
Current body:
|
||||
{body}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecifyOutcome:
|
||||
"""Result of specifying a single triage task."""
|
||||
|
||||
task_id: str
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
new_title: Optional[str] = None
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1] + "…"
|
||||
|
||||
|
||||
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json_blob(raw: str) -> Optional[dict]:
|
||||
"""Lenient JSON extraction — tolerates fenced code blocks and
|
||||
leading/trailing whitespace. Returns None if nothing parses."""
|
||||
if not raw:
|
||||
return None
|
||||
stripped = _FENCE_RE.sub("", raw.strip())
|
||||
# Greedy: find the first `{` and last `}` and try that slice.
|
||||
first = stripped.find("{")
|
||||
last = stripped.rfind("}")
|
||||
if first == -1 or last == -1 or last <= first:
|
||||
return None
|
||||
candidate = stripped[first : last + 1]
|
||||
try:
|
||||
val = json.loads(candidate)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(val, dict):
|
||||
return None
|
||||
return val
|
||||
|
||||
|
||||
def _profile_author() -> str:
|
||||
"""Mirror of ``hermes_cli.kanban._profile_author``. Kept local to
|
||||
avoid a circular import when kanban.py imports this module."""
|
||||
return (
|
||||
os.environ.get("HERMES_PROFILE")
|
||||
or os.environ.get("USER")
|
||||
or "specifier"
|
||||
)
|
||||
|
||||
|
||||
def specify_task(
|
||||
task_id: str,
|
||||
*,
|
||||
author: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> SpecifyOutcome:
|
||||
"""Specify a single triage task and promote it to ``todo``.
|
||||
|
||||
Returns an outcome describing what happened. Never raises for expected
|
||||
failure modes (task not in triage, no aux client configured, API
|
||||
error, malformed response) — those surface via ``ok=False`` so the
|
||||
``--all`` sweep can continue past individual failures.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
task = kb.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return SpecifyOutcome(task_id, False, "unknown task id")
|
||||
if task.status != "triage":
|
||||
return SpecifyOutcome(
|
||||
task_id, False, f"task is not in triage (status={task.status!r})"
|
||||
)
|
||||
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
except Exception as exc: # pragma: no cover — import smoke test
|
||||
logger.debug("specify: auxiliary client import failed: %s", exc)
|
||||
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
try:
|
||||
client, model = get_text_auxiliary_client("triage_specifier")
|
||||
except Exception as exc:
|
||||
logger.debug("specify: get_text_auxiliary_client failed: %s", exc)
|
||||
return SpecifyOutcome(task_id, False, "auxiliary client unavailable")
|
||||
|
||||
if client is None or not model:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "no auxiliary client configured"
|
||||
)
|
||||
|
||||
user_msg = _USER_TEMPLATE.format(
|
||||
task_id=task.id,
|
||||
title=_truncate(task.title or "", 400),
|
||||
body=_truncate(task.body or "(no body)", 4000),
|
||||
)
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=1500,
|
||||
timeout=timeout or 120,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"specify: API call failed for %s (%s) — skipping",
|
||||
task_id, exc,
|
||||
)
|
||||
return SpecifyOutcome(
|
||||
task_id, False, f"LLM error: {type(exc).__name__}"
|
||||
)
|
||||
|
||||
try:
|
||||
raw = resp.choices[0].message.content or ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
parsed = _extract_json_blob(raw)
|
||||
|
||||
new_title: Optional[str]
|
||||
new_body: Optional[str]
|
||||
if parsed is None:
|
||||
# Fall back: treat the whole reply as the body, leave title as-is.
|
||||
# Worst case the user edits afterward — still better than stranding
|
||||
# the task in triage on a malformed LLM reply.
|
||||
stripped_raw = raw.strip()
|
||||
if not stripped_raw:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "LLM returned an empty response"
|
||||
)
|
||||
new_title = None
|
||||
new_body = stripped_raw
|
||||
else:
|
||||
title_val = parsed.get("title")
|
||||
body_val = parsed.get("body")
|
||||
new_title = (
|
||||
title_val.strip()
|
||||
if isinstance(title_val, str) and title_val.strip()
|
||||
else None
|
||||
)
|
||||
new_body = (
|
||||
body_val if isinstance(body_val, str) and body_val.strip() else None
|
||||
)
|
||||
if new_body is None and new_title is None:
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "LLM response missing title and body"
|
||||
)
|
||||
|
||||
with kb.connect() as conn:
|
||||
ok = kb.specify_triage_task(
|
||||
conn,
|
||||
task_id,
|
||||
title=new_title,
|
||||
body=new_body,
|
||||
author=author or _profile_author(),
|
||||
)
|
||||
if not ok:
|
||||
# Race: someone else promoted / archived the task between our
|
||||
# read above and the write. Report, don't crash.
|
||||
return SpecifyOutcome(
|
||||
task_id, False, "task moved out of triage before promotion"
|
||||
)
|
||||
return SpecifyOutcome(task_id, True, "specified", new_title=new_title)
|
||||
|
||||
|
||||
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
|
||||
"""Return task ids currently in the triage column.
|
||||
|
||||
``tenant`` narrows the sweep; ``None`` returns every triage task.
|
||||
"""
|
||||
with kb.connect() as conn:
|
||||
tasks = kb.list_tasks(
|
||||
conn,
|
||||
status="triage",
|
||||
tenant=tenant,
|
||||
include_archived=False,
|
||||
)
|
||||
return [t.id for t in tasks]
|
||||
+121
-28
@@ -230,6 +230,7 @@ except Exception:
|
||||
pass # best-effort — don't crash if config isn't available yet
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time as _time
|
||||
from datetime import datetime
|
||||
|
||||
@@ -6447,6 +6448,45 @@ def _load_installable_optional_extras() -> list[str]:
|
||||
return referenced
|
||||
|
||||
|
||||
def _run_install_with_heartbeat(
|
||||
cmd: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
heartbeat_interval_seconds: int = 30,
|
||||
) -> None:
|
||||
"""Run dependency install command with periodic heartbeat output.
|
||||
|
||||
Some resolvers/build backends (especially when compiling Rust/C extensions)
|
||||
can stay quiet for minutes. Emit a simple elapsed-time heartbeat so users
|
||||
know ``hermes update`` is still progressing even if pip/uv itself is silent.
|
||||
"""
|
||||
done = threading.Event()
|
||||
start = _time.time()
|
||||
|
||||
def _heartbeat() -> None:
|
||||
# Wait first, then print, so short installs don't emit noise.
|
||||
while not done.wait(heartbeat_interval_seconds):
|
||||
elapsed = int(_time.time() - start)
|
||||
print(
|
||||
f" … still installing dependencies ({elapsed}s elapsed)"
|
||||
" — compiling Rust/C extensions can take several minutes",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
t = threading.Thread(target=_heartbeat, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
finally:
|
||||
done.set()
|
||||
t.join(timeout=0.2)
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
@@ -6463,12 +6503,13 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", ".[all]"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
return
|
||||
@@ -6477,10 +6518,8 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..."
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", "."],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
@@ -6488,10 +6527,8 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
installed_extras: list[str] = []
|
||||
for extra in _load_installable_optional_extras():
|
||||
try:
|
||||
subprocess.run(
|
||||
_run_install_with_heartbeat(
|
||||
install_cmd_prefix + ["install", "-e", f".[{extra}]"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
env=env,
|
||||
)
|
||||
installed_extras.append(extra)
|
||||
@@ -7337,7 +7374,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
for p in all_profiles:
|
||||
try:
|
||||
r = seed_profile_skills(p.path, quiet=True)
|
||||
if r:
|
||||
if r and r.get("skipped_opt_out"):
|
||||
status = "opted out (--no-skills)"
|
||||
elif r:
|
||||
copied = len(r.get("copied", []))
|
||||
updated = len(r.get("updated", []))
|
||||
modified = len(r.get("user_modified", []))
|
||||
@@ -7408,11 +7447,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
.lower()
|
||||
)
|
||||
elif not (sys.stdin.isatty() and sys.stdout.isatty()):
|
||||
print(" ℹ Non-interactive session — skipping config migration prompt.")
|
||||
print(
|
||||
" Run 'hermes config migrate' later to apply any new config/env options."
|
||||
)
|
||||
response = "n"
|
||||
print(" ℹ Non-interactive session — applying safe config migrations.")
|
||||
response = "auto"
|
||||
else:
|
||||
try:
|
||||
response = (
|
||||
@@ -7423,19 +7459,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except EOFError:
|
||||
response = "n"
|
||||
|
||||
if response in ("", "y", "yes"):
|
||||
if response in ("", "y", "yes", "auto"):
|
||||
print()
|
||||
# In gateway mode OR under --yes, run auto-migrations only (no
|
||||
# input() prompts for API keys which would hang the detached
|
||||
# process / defeat the point of --yes).
|
||||
results = migrate_config(
|
||||
interactive=not (gateway_mode or assume_yes), quiet=False
|
||||
# Gateway mode, --yes, and non-interactive update contexts
|
||||
# (dashboard / web server actions) cannot prompt for API keys.
|
||||
# Still run the non-interactive migration pass before restarting
|
||||
# so new default config fields and version bumps are written
|
||||
# before the freshly updated gateway validates config at startup.
|
||||
interactive_migration = not (
|
||||
gateway_mode or assume_yes or response == "auto"
|
||||
)
|
||||
results = migrate_config(interactive=interactive_migration, quiet=False)
|
||||
|
||||
if results["env_added"] or results["config_added"]:
|
||||
print()
|
||||
print("✓ Configuration updated!")
|
||||
if (gateway_mode or assume_yes) and missing_env:
|
||||
if (gateway_mode or assume_yes or response == "auto") and missing_env:
|
||||
print(" ℹ API keys require manual entry: hermes config migrate")
|
||||
else:
|
||||
print()
|
||||
@@ -7739,6 +7778,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# when the graceful path failed (unit missing
|
||||
# SIGUSR1 wiring, drain exceeded the budget,
|
||||
# restart-policy mismatch).
|
||||
#
|
||||
# Always `reset-failed` first. If systemd's own
|
||||
# auto-restart attempts already parked the unit
|
||||
# in a failed state (transient CHDIR / OOM /
|
||||
# filesystem race after our drain + exit-75),
|
||||
# a plain `systemctl restart` can wedge against
|
||||
# the RestartSec backoff and leave the unit
|
||||
# dead. Clearing the failed state first makes
|
||||
# the restart idempotent. Mirrors the recovery
|
||||
# path in `hermes gateway restart`
|
||||
# (`systemd_restart()`) as of PR #20949.
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
restart = subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
@@ -7758,10 +7814,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
# Retry once — transient startup failures
|
||||
# (stale module cache, import race) often
|
||||
# resolve on the second attempt.
|
||||
# resolve on the second attempt. Again
|
||||
# clear any failed state first so the
|
||||
# retry isn't blocked by the previous
|
||||
# crash.
|
||||
print(
|
||||
f" ⚠ {svc_name} died after restart, retrying..."
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["reset-failed", svc_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
subprocess.run(
|
||||
scope_cmd + ["restart", svc_name],
|
||||
capture_output=True,
|
||||
@@ -7776,10 +7841,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
restarted_services.append(svc_name)
|
||||
print(f" ✓ {svc_name} recovered on retry")
|
||||
else:
|
||||
_scope_flag = "--user " if scope == "user" else ""
|
||||
print(
|
||||
f" ✗ {svc_name} failed to stay running after restart.\n"
|
||||
f" Check logs: journalctl --user -u {svc_name} --since '2 min ago'\n"
|
||||
f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}"
|
||||
f" Check logs: journalctl {_scope_flag}-u {svc_name} --since '2 min ago'\n"
|
||||
f" Recover manually:\n"
|
||||
f" systemctl {_scope_flag}reset-failed {svc_name}\n"
|
||||
f" systemctl {_scope_flag}restart {svc_name}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
@@ -8130,6 +8198,7 @@ def cmd_profile(args):
|
||||
clone = getattr(args, "clone", False)
|
||||
clone_all = getattr(args, "clone_all", False)
|
||||
no_alias = getattr(args, "no_alias", False)
|
||||
no_skills = getattr(args, "no_skills", False)
|
||||
|
||||
try:
|
||||
clone_from = getattr(args, "clone_from", None)
|
||||
@@ -8140,6 +8209,7 @@ def cmd_profile(args):
|
||||
clone_all=clone_all,
|
||||
clone_config=clone,
|
||||
no_alias=no_alias,
|
||||
no_skills=no_skills,
|
||||
)
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
@@ -8164,10 +8234,17 @@ def cmd_profile(args):
|
||||
except Exception:
|
||||
pass # Honcho plugin not installed or not configured
|
||||
|
||||
# Seed bundled skills (skip if --clone-all already copied them)
|
||||
# Seed bundled skills (skip if --clone-all already copied them, or
|
||||
# if --no-skills was passed — in which case seed_profile_skills()
|
||||
# honors the marker file and returns skipped_opt_out=True).
|
||||
if not clone_all:
|
||||
result = seed_profile_skills(profile_dir)
|
||||
if result:
|
||||
if result and result.get("skipped_opt_out"):
|
||||
print(
|
||||
"No bundled skills seeded (--no-skills). "
|
||||
"Delete .no-bundled-skills in the profile to opt back in."
|
||||
)
|
||||
elif result:
|
||||
copied = len(result.get("copied", []))
|
||||
print(f"{copied} bundled skills synced.")
|
||||
else:
|
||||
@@ -8685,6 +8762,9 @@ def main():
|
||||
help="Target the Linux system-level gateway service",
|
||||
)
|
||||
|
||||
# gateway list
|
||||
gateway_subparsers.add_parser("list", help="List all profiles and their gateway status")
|
||||
|
||||
# gateway setup
|
||||
gateway_subparsers.add_parser("setup", help="Configure messaging platforms")
|
||||
|
||||
@@ -10002,7 +10082,15 @@ Examples:
|
||||
)
|
||||
mcp_add_p.add_argument("name", help="Server name (used as config key)")
|
||||
mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL")
|
||||
mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)")
|
||||
# dest="mcp_command" so this flag does not clobber the top-level
|
||||
# subparser's args.command attribute, which the dispatcher reads to
|
||||
# route to cmd_mcp. Without an explicit dest, argparse derives
|
||||
# dest="command" from the flag name and sets it to None when the
|
||||
# flag is omitted, causing `hermes mcp add ...` to fall through to
|
||||
# interactive chat.
|
||||
mcp_add_p.add_argument(
|
||||
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
|
||||
)
|
||||
mcp_add_p.add_argument(
|
||||
"--args", nargs="*", default=[], help="Arguments for stdio command"
|
||||
)
|
||||
@@ -10529,6 +10617,11 @@ Examples:
|
||||
profile_create.add_argument(
|
||||
"--no-alias", action="store_true", help="Skip wrapper script creation"
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--no-skills",
|
||||
action="store_true",
|
||||
help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)",
|
||||
)
|
||||
|
||||
profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile")
|
||||
profile_delete.add_argument("profile_name", help="Profile to delete")
|
||||
|
||||
@@ -221,7 +221,10 @@ def cmd_mcp_add(args):
|
||||
"""Add a new MCP server with discovery-first tool selection."""
|
||||
name = args.name
|
||||
url = getattr(args, "url", None)
|
||||
command = getattr(args, "command", None)
|
||||
# Read from `mcp_command` (set by --command via explicit dest) — see
|
||||
# mcp_add_p.add_argument("--command", dest="mcp_command", ...) in
|
||||
# hermes_cli/main.py for why the dest is renamed.
|
||||
command = getattr(args, "mcp_command", None)
|
||||
cmd_args = getattr(args, "args", None) or []
|
||||
auth_type = getattr(args, "auth", None)
|
||||
preset_name = getattr(args, "preset", None)
|
||||
|
||||
@@ -1637,7 +1637,8 @@ def list_authenticated_providers(
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp in groups.values():
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
@@ -1675,6 +1676,18 @@ def list_authenticated_providers(
|
||||
_grp_url_norm = _pair_key[1]
|
||||
if _grp_url_norm and _grp_url_norm in _builtin_endpoints:
|
||||
continue
|
||||
# Live model discovery from custom provider endpoints (matches
|
||||
# Section 3 behavior for user ``providers:`` entries).
|
||||
if api_url and api_key:
|
||||
try:
|
||||
from hermes_cli.models import fetch_api_models
|
||||
|
||||
live_models = fetch_api_models(api_key, api_url)
|
||||
if live_models:
|
||||
grp["models"] = live_models
|
||||
grp["total_models"] = len(live_models)
|
||||
except Exception:
|
||||
pass
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
|
||||
@@ -46,6 +46,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
("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", ""),
|
||||
@@ -416,6 +417,18 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"glm-4.7",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
# Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl),
|
||||
# separate provider ID with its own base_url_env_var.
|
||||
"alibaba-coding-plan": [
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
"qwen3-coder-plus",
|
||||
"qwen3-coder-next",
|
||||
"kimi-k2.5",
|
||||
"glm-5",
|
||||
"glm-4.7",
|
||||
"MiniMax-M2.5",
|
||||
],
|
||||
# Curated HF model list — only agentic models that map to OpenRouter defaults.
|
||||
"huggingface": [
|
||||
"moonshotai/Kimi-K2.5",
|
||||
|
||||
@@ -73,6 +73,24 @@ def _cmd_approve(store, platform: str, code: str):
|
||||
display = f"{name} ({uid})" if name else uid
|
||||
print(f"\n Approved! User {display} on {platform} can now use the bot~")
|
||||
print(" They'll be recognized automatically on their next message.\n")
|
||||
elif store._is_locked_out(platform):
|
||||
# Disambiguate: approve_code returns None for both invalid codes
|
||||
# and lockout. Tell the operator it's lockout so they don't chase
|
||||
# a "wrong code" rabbit hole (#10195).
|
||||
import time as _time
|
||||
limits = store._load_json(store._rate_limit_path())
|
||||
lockout_until = limits.get(f"_lockout:{platform}", 0)
|
||||
remaining = max(0, int(lockout_until - _time.time()))
|
||||
mins = remaining // 60
|
||||
print(
|
||||
f"\n Platform '{platform}' is locked out after too many failed "
|
||||
f"approval attempts."
|
||||
)
|
||||
print(f" Lockout clears in ~{mins} minute(s).")
|
||||
print(
|
||||
" To reset sooner, delete the '_lockout:{0}' entry from "
|
||||
"~/.hermes/platforms/pairing/_rate_limits.json\n".format(platform)
|
||||
)
|
||||
else:
|
||||
print(f"\n Code '{code}' not found or expired for platform '{platform}'.")
|
||||
print(" Run 'hermes pairing list' to see pending codes.\n")
|
||||
|
||||
@@ -80,6 +80,10 @@ VALID_HOOKS: Set[str] = {
|
||||
"post_tool_call",
|
||||
"transform_terminal_output",
|
||||
"transform_tool_result",
|
||||
# Transform LLM output before it's returned to the user.
|
||||
# Plugins return a string to replace the response text, or None/empty to leave unchanged.
|
||||
# First non-None string wins. Useful for vocabulary/personality transformation.
|
||||
"transform_llm_output",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
"pre_api_request",
|
||||
|
||||
@@ -71,6 +71,22 @@ _CLONE_ALL_STRIP = [
|
||||
"processes.json",
|
||||
]
|
||||
|
||||
# Marker file written by `hermes profile create --no-skills`. When present in
|
||||
# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes
|
||||
# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding
|
||||
# for that profile. The user can still install skills manually via
|
||||
# `hermes skills install` or drop SKILL.md files into the profile's skills/.
|
||||
# Delete the marker file to opt back in.
|
||||
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
|
||||
|
||||
|
||||
def has_bundled_skills_opt_out(profile_dir: Path) -> bool:
|
||||
"""Return True if the profile opted out of bundled-skill seeding."""
|
||||
try:
|
||||
return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _clone_all_copytree_ignore(source_dir: Path):
|
||||
"""Ignore ``profiles/`` at the root of *source_dir* only.
|
||||
@@ -427,6 +443,7 @@ def create_profile(
|
||||
clone_all: bool = False,
|
||||
clone_config: bool = False,
|
||||
no_alias: bool = False,
|
||||
no_skills: bool = False,
|
||||
) -> Path:
|
||||
"""Create a new profile directory.
|
||||
|
||||
@@ -444,12 +461,22 @@ def create_profile(
|
||||
skills, and selected profile identity files from the source profile.
|
||||
no_alias:
|
||||
If True, skip wrapper script creation.
|
||||
no_skills:
|
||||
If True, create an empty profile with no bundled skills, and write
|
||||
a marker file so ``hermes update`` skips re-seeding this profile's
|
||||
skills. Mutually exclusive with ``clone_config``/``clone_all`` (those
|
||||
explicitly copy skills from the source).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The newly created profile directory.
|
||||
"""
|
||||
if no_skills and (clone_config or clone_all):
|
||||
raise ValueError(
|
||||
"--no-skills is mutually exclusive with --clone / --clone-all "
|
||||
"(cloning explicitly copies skills from the source profile)."
|
||||
)
|
||||
canon = normalize_profile_name(name)
|
||||
validate_profile_name(canon)
|
||||
|
||||
@@ -527,6 +554,19 @@ def create_profile(
|
||||
except Exception:
|
||||
pass # best-effort — don't fail profile creation over this
|
||||
|
||||
# Write the opt-out marker so seed_profile_skills() and `hermes update`'s
|
||||
# all-profile sync loop both skip this profile for bundled-skill seeding.
|
||||
if no_skills:
|
||||
try:
|
||||
(profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text(
|
||||
"This profile opted out of bundled-skill seeding "
|
||||
"(`hermes profile create --no-skills`).\n"
|
||||
"Delete this file to re-enable sync on the next `hermes update`.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
pass # best-effort — the feature still works via the empty skills/ dir
|
||||
|
||||
return profile_dir
|
||||
|
||||
|
||||
@@ -535,7 +575,19 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
|
||||
|
||||
Uses subprocess because sync_skills() caches HERMES_HOME at module level.
|
||||
Returns the sync result dict, or None on failure.
|
||||
|
||||
Profiles that opted out of bundled skills (via ``hermes profile create
|
||||
--no-skills`` — which writes ``.no-bundled-skills`` to the profile root)
|
||||
are skipped and get an empty-result dict so callers can report
|
||||
"opted out" instead of "failed".
|
||||
"""
|
||||
if has_bundled_skills_opt_out(profile_dir):
|
||||
return {
|
||||
"copied": [],
|
||||
"updated": [],
|
||||
"user_modified": [],
|
||||
"skipped_opt_out": True,
|
||||
}
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -319,9 +319,10 @@ def _try_resolve_from_custom_pool(
|
||||
base_url: str,
|
||||
provider_label: str,
|
||||
api_mode_override: Optional[str] = None,
|
||||
provider_name: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Check if a credential pool exists for a custom endpoint and return a runtime dict if so."""
|
||||
pool_key = get_custom_provider_pool_key(base_url)
|
||||
pool_key = get_custom_provider_pool_key(base_url, provider_name=provider_name)
|
||||
if not pool_key:
|
||||
return None
|
||||
try:
|
||||
@@ -521,7 +522,7 @@ def _resolve_named_custom_runtime(
|
||||
return None
|
||||
|
||||
# Check if a credential pool exists for this custom endpoint
|
||||
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"))
|
||||
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name"))
|
||||
if pool_result:
|
||||
# Propagate the model name even when using pooled credentials —
|
||||
# the pool doesn't know about the custom_providers model field.
|
||||
@@ -640,8 +641,11 @@ def _resolve_openrouter_runtime(
|
||||
|
||||
# For custom endpoints, check if a credential pool exists
|
||||
if effective_provider == "custom" and base_url:
|
||||
# Pass requested_provider so pool lookup prefers name match over base_url,
|
||||
# fixing credential mix-ups when multiple custom providers share a base_url.
|
||||
pool_result = _try_resolve_from_custom_pool(
|
||||
base_url, effective_provider, _parse_api_mode(model_cfg.get("api_mode")),
|
||||
provider_name=requested_provider if requested_norm != "custom" else None,
|
||||
)
|
||||
if pool_result:
|
||||
return pool_result
|
||||
|
||||
+22
-2
@@ -2462,6 +2462,9 @@ def setup_gateway(config: dict):
|
||||
launchd_start,
|
||||
launchd_restart,
|
||||
UserSystemdUnavailableError,
|
||||
SystemScopeRequiresRootError,
|
||||
_system_scope_wizard_would_need_root,
|
||||
_print_system_scope_remediation,
|
||||
)
|
||||
|
||||
service_installed = _is_service_installed()
|
||||
@@ -2479,7 +2482,9 @@ def setup_gateway(config: dict):
|
||||
print()
|
||||
|
||||
if service_running:
|
||||
if prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("restart")
|
||||
elif prompt_yes_no(" Restart the gateway to pick up changes?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_restart()
|
||||
@@ -2489,10 +2494,19 @@ def setup_gateway(config: dict):
|
||||
print_error(" Restart failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
# Defense in depth: the pre-check above should have
|
||||
# caught this, but a race (unit file appearing mid-run)
|
||||
# could still land here. Previously this exited the
|
||||
# whole wizard via sys.exit(1).
|
||||
print_error(f" Restart failed: {e}")
|
||||
_print_system_scope_remediation("restart")
|
||||
except Exception as e:
|
||||
print_error(f" Restart failed: {e}")
|
||||
elif service_installed:
|
||||
if prompt_yes_no(" Start the gateway service?", True):
|
||||
if supports_systemd and _system_scope_wizard_would_need_root():
|
||||
_print_system_scope_remediation("start")
|
||||
elif prompt_yes_no(" Start the gateway service?", True):
|
||||
try:
|
||||
if supports_systemd:
|
||||
systemd_start()
|
||||
@@ -2502,6 +2516,9 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
elif supports_service_manager:
|
||||
@@ -2529,6 +2546,9 @@ def setup_gateway(config: dict):
|
||||
print_error(" Start failed — user systemd not reachable:")
|
||||
for line in str(e).splitlines():
|
||||
print(f" {line}")
|
||||
except SystemScopeRequiresRootError as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except Exception as e:
|
||||
print_error(f" Start failed: {e}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -42,6 +42,7 @@ All fields are optional. Missing values inherit from the ``default`` skin.
|
||||
session_border: "#8B8682" # Session ID dim color
|
||||
status_bar_bg: "#1a1a2e" # TUI status/usage bar background
|
||||
voice_status_bg: "#1a1a2e" # TUI voice status background
|
||||
selection_bg: "#333355" # TUI mouse-selection highlight background
|
||||
completion_menu_bg: "#1a1a2e" # Completion menu background
|
||||
completion_menu_current_bg: "#333355" # Active completion row background
|
||||
completion_menu_meta_bg: "#1a1a2e" # Completion meta column background
|
||||
|
||||
@@ -308,6 +308,23 @@ TOOL_CATEGORIES = {
|
||||
{"key": "SEARXNG_URL", "prompt": "Your SearXNG instance URL (e.g., http://localhost:8080)", "url": "https://searxng.github.io/searxng/"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Brave Search (Free Tier)",
|
||||
"badge": "free tier · search only",
|
||||
"tag": "2,000 queries/mo free — search only (pair with any extract provider)",
|
||||
"web_backend": "brave-free",
|
||||
"env_vars": [
|
||||
{"key": "BRAVE_SEARCH_API_KEY", "prompt": "Brave Search subscription token", "url": "https://brave.com/search/api/"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "DuckDuckGo (ddgs)",
|
||||
"badge": "free · no key · search only",
|
||||
"tag": "Search via the ddgs Python package — no API key (pair with any extract provider)",
|
||||
"web_backend": "ddgs",
|
||||
"env_vars": [],
|
||||
"post_setup": "ddgs",
|
||||
},
|
||||
],
|
||||
},
|
||||
"image_gen": {
|
||||
@@ -669,6 +686,32 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_info(" Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md")
|
||||
_print_info(" Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml")
|
||||
|
||||
elif post_setup_key == "ddgs":
|
||||
try:
|
||||
__import__("ddgs")
|
||||
_print_success(" ddgs is already installed")
|
||||
except ImportError:
|
||||
import subprocess
|
||||
_print_info(" Installing ddgs (DuckDuckGo search package)...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "-U", "ddgs", "--quiet"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
_print_success(" ddgs installed")
|
||||
else:
|
||||
_print_warning(" ddgs install failed:")
|
||||
_print_info(f" {result.stderr.strip()[:300]}")
|
||||
_print_info(" Run manually: python -m pip install -U ddgs")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
_print_warning(" ddgs install timed out (>5min)")
|
||||
_print_info(" Run manually: python -m pip install -U ddgs")
|
||||
return
|
||||
_print_info(" No API key required. DuckDuckGo enforces server-side rate limits.")
|
||||
_print_info(" Pair with an extract provider if you also need web_extract.")
|
||||
|
||||
elif post_setup_key == "spotify":
|
||||
# Run the full `hermes auth spotify` flow — if the user has no
|
||||
# client_id yet, this drops them into the interactive wizard
|
||||
|
||||
+148
-36
@@ -281,6 +281,8 @@ _recorder_lock = threading.Lock()
|
||||
# ── Continuous (VAD) state ───────────────────────────────────────────
|
||||
_continuous_lock = threading.Lock()
|
||||
_continuous_active = False
|
||||
_continuous_stopping = False
|
||||
_continuous_auto_restart: bool = True
|
||||
_continuous_recorder: Any = None
|
||||
|
||||
# ── TTS-vs-STT feedback guard ────────────────────────────────────────
|
||||
@@ -370,32 +372,43 @@ def start_continuous(
|
||||
on_silent_limit: Optional[Callable[[], None]] = None,
|
||||
silence_threshold: int = 200,
|
||||
silence_duration: float = 3.0,
|
||||
) -> None:
|
||||
auto_restart: bool = True,
|
||||
) -> bool:
|
||||
"""Start a VAD-driven continuous recording loop.
|
||||
|
||||
The loop calls ``on_transcript(text)`` each time speech is detected and
|
||||
transcribed successfully, then auto-restarts. After
|
||||
``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit``
|
||||
so the UI can reflect "voice off". Idempotent — calling while already
|
||||
active is a no-op.
|
||||
transcribed successfully. If ``auto_restart`` is True, it auto-restarts
|
||||
for the next turn and resets the no-speech counter for that loop. If
|
||||
``auto_restart`` is False, the first silence-triggered transcription ends
|
||||
the loop and reports ``"idle"``; no-speech counts are retained across
|
||||
starts so a push-to-talk caller can still enforce the three-strikes guard.
|
||||
After ``_CONTINUOUS_NO_SPEECH_LIMIT`` consecutive silent cycles (no speech
|
||||
picked up at all) the loop stops itself and calls ``on_silent_limit`` so the
|
||||
UI can reflect "voice off". Returns False if a previous stop is still
|
||||
transcribing/cleaning up; otherwise returns True. Idempotent — calling while
|
||||
already active is a successful no-op.
|
||||
|
||||
``on_status`` is called with ``"listening"`` / ``"transcribing"`` /
|
||||
``"idle"`` so the UI can show a live indicator.
|
||||
"""
|
||||
global _continuous_active, _continuous_recorder
|
||||
global _continuous_active, _continuous_recorder, _continuous_auto_restart
|
||||
global _continuous_on_transcript, _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_no_speech_count
|
||||
|
||||
with _continuous_lock:
|
||||
if _continuous_active:
|
||||
_debug("start_continuous: already active — no-op")
|
||||
return
|
||||
return True
|
||||
if _continuous_stopping:
|
||||
_debug("start_continuous: stop/transcribe in progress — busy")
|
||||
return False
|
||||
_continuous_active = True
|
||||
_continuous_auto_restart = auto_restart
|
||||
_continuous_on_transcript = on_transcript
|
||||
_continuous_on_status = on_status
|
||||
_continuous_on_silent_limit = on_silent_limit
|
||||
_continuous_no_speech_count = 0
|
||||
if auto_restart:
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if _continuous_recorder is None:
|
||||
_continuous_recorder = create_audio_recorder()
|
||||
@@ -428,15 +441,18 @@ def start_continuous(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def stop_continuous() -> None:
|
||||
|
||||
def stop_continuous(force_transcribe: bool = False) -> None:
|
||||
"""Stop the active continuous loop and release the microphone.
|
||||
|
||||
Idempotent — calling while not active is a no-op. Any in-flight
|
||||
transcription completes but its result is discarded (the callback
|
||||
checks ``_continuous_active`` before firing).
|
||||
Idempotent — calling while not active is a no-op. If ``force_transcribe`` is
|
||||
True, the recorder stops synchronously, then transcription/cleanup runs on a
|
||||
background thread before reporting ``"idle"``. Otherwise the buffer is
|
||||
discarded.
|
||||
"""
|
||||
global _continuous_active, _continuous_on_transcript
|
||||
global _continuous_active, _continuous_on_transcript, _continuous_stopping
|
||||
global _continuous_on_status, _continuous_on_silent_limit
|
||||
global _continuous_recorder, _continuous_no_speech_count
|
||||
|
||||
@@ -446,18 +462,98 @@ def stop_continuous() -> None:
|
||||
_continuous_active = False
|
||||
rec = _continuous_recorder
|
||||
on_status = _continuous_on_status
|
||||
on_transcript = _continuous_on_transcript
|
||||
on_silent_limit = _continuous_on_silent_limit
|
||||
auto_restart = _continuous_auto_restart
|
||||
track_no_speech = force_transcribe and not auto_restart
|
||||
_continuous_stopping = rec is not None
|
||||
_continuous_on_transcript = None
|
||||
_continuous_on_status = None
|
||||
_continuous_on_silent_limit = None
|
||||
_continuous_no_speech_count = 0
|
||||
if not track_no_speech:
|
||||
_continuous_no_speech_count = 0
|
||||
|
||||
if rec is not None:
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
if force_transcribe and on_transcript:
|
||||
if on_status:
|
||||
try:
|
||||
on_status("transcribing")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
wav_path = rec.stop()
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop recorder: %s", e)
|
||||
try:
|
||||
rec.cancel()
|
||||
except Exception as cancel_error:
|
||||
logger.warning("failed to cancel recorder: %s", cancel_error)
|
||||
wav_path = None
|
||||
|
||||
def _transcribe_and_cleanup():
|
||||
global _continuous_no_speech_count, _continuous_stopping
|
||||
transcript: Optional[str] = None
|
||||
should_halt = False
|
||||
|
||||
try:
|
||||
if wav_path:
|
||||
try:
|
||||
result = transcribe_recording(wav_path)
|
||||
if result.get("success"):
|
||||
text = (result.get("transcript") or "").strip()
|
||||
if text and not is_whisper_hallucination(text):
|
||||
transcript = text
|
||||
finally:
|
||||
if os.path.isfile(wav_path):
|
||||
os.unlink(wav_path)
|
||||
except Exception as e:
|
||||
logger.warning("failed to stop/transcribe recorder: %s", e)
|
||||
finally:
|
||||
if transcript:
|
||||
try:
|
||||
on_transcript(transcript)
|
||||
except Exception as e:
|
||||
logger.warning("on_transcript callback raised: %s", e)
|
||||
|
||||
if track_no_speech:
|
||||
with _continuous_lock:
|
||||
if transcript:
|
||||
_continuous_no_speech_count = 0
|
||||
else:
|
||||
_continuous_no_speech_count += 1
|
||||
should_halt = (
|
||||
_continuous_no_speech_count
|
||||
>= _CONTINUOUS_NO_SPEECH_LIMIT
|
||||
)
|
||||
if should_halt:
|
||||
_continuous_no_speech_count = 0
|
||||
if should_halt and on_silent_limit:
|
||||
try:
|
||||
on_silent_limit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_play_beep(frequency=660, count=2)
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_transcribe_and_cleanup, daemon=True).start()
|
||||
return
|
||||
else:
|
||||
try:
|
||||
# cancel() (not stop()) discards buffered frames — the loop
|
||||
# is over, we don't want to transcribe a half-captured turn.
|
||||
rec.cancel()
|
||||
except Exception as e:
|
||||
logger.warning("failed to cancel recorder: %s", e)
|
||||
|
||||
with _continuous_lock:
|
||||
_continuous_stopping = False
|
||||
|
||||
# Audible "recording stopped" cue (CLI parity: same 660 Hz × 2 the
|
||||
# silence-auto-stop path plays).
|
||||
@@ -603,23 +699,39 @@ def _continuous_on_silence() -> None:
|
||||
_debug("_continuous_on_silence: stopped while waiting for TTS")
|
||||
return
|
||||
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
if _continuous_auto_restart:
|
||||
# Restart for the next turn.
|
||||
_debug(f"_continuous_on_silence: restarting loop (no_speech={no_speech})")
|
||||
_play_beep(frequency=880, count=1)
|
||||
try:
|
||||
rec.start(on_silence_stop=_continuous_on_silence)
|
||||
except Exception as e:
|
||||
logger.error("failed to restart continuous recording: %s", e)
|
||||
_debug(f"_continuous_on_silence: restart raised {type(e).__name__}: {e}")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Do not auto-restart. Clean up state and notify idle.
|
||||
_debug("_continuous_on_silence: auto_restart=False, stopping loop")
|
||||
with _continuous_lock:
|
||||
_continuous_active = False
|
||||
return
|
||||
|
||||
if on_status:
|
||||
try:
|
||||
on_status("listening")
|
||||
except Exception:
|
||||
pass
|
||||
if on_status:
|
||||
try:
|
||||
on_status("idle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── TTS API ──────────────────────────────────────────────────────────
|
||||
|
||||
+182
-11
@@ -55,7 +55,7 @@ from gateway.status import get_running_pid, read_runtime_status
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
@@ -2109,8 +2109,8 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
name=f"oauth-codex-{sid[:6]}",
|
||||
).start()
|
||||
# Block briefly until the worker has populated the user_code, OR error.
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(sid)
|
||||
if s and (s.get("user_code") or s["status"] != "pending"):
|
||||
@@ -2244,10 +2244,10 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
sess["expires_at"] = time.time() + sess["expires_in"]
|
||||
|
||||
# Step 2: poll until authorized
|
||||
deadline = time.time() + sess["expires_in"]
|
||||
deadline = time.monotonic() + sess["expires_in"]
|
||||
code_resp = None
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
while time.time() < deadline:
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(poll_interval)
|
||||
poll = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/token",
|
||||
@@ -2405,6 +2405,83 @@ async def cancel_oauth_session(session_id: str, request: Request):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def _session_latest_descendant(session_id: str):
|
||||
"""Resolve a session id to the newest child leaf session.
|
||||
|
||||
/model may create child sessions. Dashboard refresh should continue the
|
||||
newest child instead of reopening the old parent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
def row_get(row, key, index):
|
||||
if isinstance(row, dict):
|
||||
return row.get(key)
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
try:
|
||||
return row[index]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid or not db.get_session(sid):
|
||||
return None, []
|
||||
|
||||
conn = (
|
||||
getattr(db, "conn", None)
|
||||
or getattr(db, "_conn", None)
|
||||
or getattr(db, "connection", None)
|
||||
or getattr(db, "_connection", None)
|
||||
)
|
||||
|
||||
rows = []
|
||||
if conn is not None:
|
||||
raw_rows = conn.execute(
|
||||
"SELECT id, parent_session_id, started_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
})
|
||||
else:
|
||||
rows = db.list_sessions_rich(limit=10000, offset=0)
|
||||
|
||||
children = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
current = sid
|
||||
path = [sid]
|
||||
seen = {sid}
|
||||
|
||||
while children.get(current):
|
||||
candidates = [r for r in children[current] if r.get("id") not in seen]
|
||||
if not candidates:
|
||||
break
|
||||
candidates.sort(key=started, reverse=True)
|
||||
current = candidates[0]["id"]
|
||||
path.append(current)
|
||||
seen.add(current)
|
||||
|
||||
return current, path
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -2419,6 +2496,19 @@ async def get_session_detail(session_id: str):
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/latest-descendant")
|
||||
async def get_session_latest_descendant(session_id: str):
|
||||
latest, path = _session_latest_descendant(session_id)
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {
|
||||
"requested_session_id": path[0] if path else session_id,
|
||||
"session_id": latest,
|
||||
"path": path,
|
||||
"changed": bool(path and latest != path[0]),
|
||||
}
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -2620,6 +2710,7 @@ async def delete_cron_job(job_id: str):
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from_default: bool = False
|
||||
no_skills: bool = False
|
||||
|
||||
|
||||
class ProfileRename(BaseModel):
|
||||
@@ -2725,11 +2816,13 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
name=body.name,
|
||||
clone_from="default" if body.clone_from_default else None,
|
||||
clone_config=body.clone_from_default,
|
||||
no_skills=body.no_skills,
|
||||
)
|
||||
# Match the CLI's profile-create flow: fresh named profiles get the
|
||||
# bundled skills installed. When cloning from default, create_profile()
|
||||
# has already copied the source profile's skills, including any
|
||||
# user-installed skills.
|
||||
# user-installed skills. When no_skills=True, create_profile() wrote
|
||||
# the opt-out marker and seed_profile_skills() will no-op.
|
||||
if not body.clone_from_default:
|
||||
profiles_mod.seed_profile_skills(path, quiet=True)
|
||||
|
||||
@@ -3200,8 +3293,18 @@ def _resolve_chat_argv(
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
||||
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
||||
# events are consumed by the TUI and forwarded as terminal input, which
|
||||
# makes browser-side transcript scrolling feel broken. Keep the terminal
|
||||
# build unchanged for native CLI usage; only disable mouse tracking for
|
||||
# the dashboard PTY path.
|
||||
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
|
||||
|
||||
if resume:
|
||||
latest_resume, _latest_path = _session_latest_descendant(resume)
|
||||
if latest_resume:
|
||||
resume = latest_resume
|
||||
env["HERMES_TUI_RESUME"] = resume
|
||||
|
||||
if sidecar_url:
|
||||
@@ -3459,12 +3562,42 @@ async def events_ws(ws: WebSocket) -> None:
|
||||
_event_channels.pop(channel, None)
|
||||
|
||||
|
||||
def _normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when
|
||||
no prefix is set / the header is malformed. We deliberately reject
|
||||
anything containing ``..`` or non-printable bytes so a hostile proxy
|
||||
can't inject HTML via the prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing.
|
||||
|
||||
The session token is injected into index.html via a ``<script>`` tag so
|
||||
the SPA can authenticate against protected API endpoints without a
|
||||
separate (unauthenticated) token-dispensing endpoint.
|
||||
|
||||
When served behind a path-prefix reverse proxy (e.g.
|
||||
``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
|
||||
proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
|
||||
rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
|
||||
and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
|
||||
without rebuilding the bundle.
|
||||
"""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
@@ -3477,24 +3610,62 @@ def mount_spa(application: FastAPI):
|
||||
|
||||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index():
|
||||
"""Return index.html with the session token injected."""
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
"""
|
||||
html = _index_path.read_text()
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
token_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";</script>'
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
html = html.replace('href="/assets/', f'href="{prefix}/assets/')
|
||||
html = html.replace('src="/assets/', f'src="{prefix}/assets/')
|
||||
html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
|
||||
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
|
||||
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
|
||||
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
|
||||
html = html.replace("</head>", f"{token_script}</head>", 1)
|
||||
return HTMLResponse(
|
||||
html,
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
# When served behind a path-prefix proxy, the built CSS contains
|
||||
# absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
|
||||
# Browsers resolve those against the document origin, which means
|
||||
# under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
|
||||
# (the MC Pages app), not the Hermes backend. Intercept CSS asset
|
||||
# requests BEFORE the StaticFiles mount and rewrite the absolute paths
|
||||
# when a prefix is in play.
|
||||
@application.get("/assets/{filename}.css")
|
||||
async def serve_css(filename: str, request: Request):
|
||||
css_path = WEB_DIST / "assets" / f"{filename}.css"
|
||||
if not css_path.is_file() or not css_path.resolve().is_relative_to(
|
||||
WEB_DIST.resolve()
|
||||
):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
css = css_path.read_text()
|
||||
if prefix:
|
||||
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
|
||||
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
|
||||
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
|
||||
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
|
||||
return Response(content=css, media_type="text/css")
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
async def serve_spa(full_path: str, request: Request):
|
||||
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
file_path = WEB_DIST / full_path
|
||||
# Prevent path traversal via url-encoded sequences (%2e%2e/)
|
||||
if (
|
||||
@@ -3504,7 +3675,7 @@ def mount_spa(application: FastAPI):
|
||||
and file_path.is_file()
|
||||
):
|
||||
return FileResponse(file_path)
|
||||
return _serve_index()
|
||||
return _serve_index(prefix)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user