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

This commit is contained in:
Brooklyn Nicholson 2026-05-04 12:47:53 -05:00
commit ca8f2c7907
182 changed files with 9843 additions and 974 deletions

View file

@ -25,3 +25,7 @@ ui-tui/packages/hermes-ink/dist/
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/

View file

@ -282,7 +282,16 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
## Adding New Tools
Requires changes in **2 files**:
For most custom or local-only tools, do **not** edit Hermes core. Use the plugin
route instead: create `~/.hermes/plugins/<name>/plugin.yaml` and
`~/.hermes/plugins/<name>/__init__.py`, then register tools with
`ctx.register_tool(...)`. Plugin toolsets are discovered automatically and can be
enabled or disabled without touching `tools/` or `toolsets.py`.
Use the built-in route below only when the user is explicitly contributing a new
core Hermes tool that should ship in the base system.
Built-in/core tools require changes in **2 files**:
**1. Create `tools/your_tool.py`:**
```python

View file

@ -76,6 +76,7 @@ _ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
@ -105,6 +106,9 @@ _ANTHROPIC_OUTPUT_LIMITS = {
"claude-3-haiku": 4_096,
# Third-party Anthropic-compatible providers
"minimax": 131_072,
# Qwen models via DashScope Anthropic-compatible endpoint
# DashScope enforces max_tokens ∈ [1, 65536]
"qwen3": 65_536,
}
# For any model not in the table, assume the highest current limit.
@ -216,6 +220,17 @@ def _forbids_sampling_params(model: str) -> bool:
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
"""Return True for models that support Anthropic Fast Mode (speed=fast).
Per Anthropic docs, fast mode is currently supported on Opus 4.6 only.
Sending ``speed: "fast"`` to any other Claude model (including Opus 4.7)
returns HTTP 400. This guard prevents silently 400'ing when stale config
or older callers leave fast mode enabled across a model upgrade.
"""
return any(v in model for v in _FAST_MODE_SUPPORTED_SUBSTRINGS)
# Beta headers for enhanced features (sent with ALL auth types).
# As of Opus 4.7 (2026-04-16), the first two are GA on Claude 4.6+ — the
# beta headers are still accepted (harmless no-op) but not required. Kept
@ -1222,6 +1237,14 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
``keep_nullable_hint=False`` because the Anthropic validator does not
recognize the OpenAPI-style ``nullable: true`` extension and strict
schema-to-grammar converters may reject unknown keywords.
Top-level ``oneOf``/``allOf``/``anyOf`` are also stripped here: the
Anthropic API rejects union keywords at the schema root with a generic
HTTP 400. Several upstream and plugin tools ship schemas with one of
these keywords at the top level (commonly for Pydantic discriminated
unions). If we land here with those keywords still present after
nullable-union stripping, drop them and fall back to a plain object
schema so the tool still validates at the Anthropic boundary.
"""
if not schema:
return {"type": "object", "properties": {}}
@ -1231,6 +1254,12 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
if not isinstance(normalized, dict):
return {"type": "object", "properties": {}}
# Strip top-level union keywords that Anthropic's validator rejects.
banned = {"oneOf", "allOf", "anyOf"}
if banned & normalized.keys():
normalized = {k: v for k, v in normalized.items() if k not in banned}
if "type" not in normalized:
normalized["type"] = "object"
if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
normalized = {**normalized, "properties": {}}
return normalized
@ -1915,9 +1944,15 @@ def build_anthropic_kwargs(
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
# output speed. Only for native Anthropic endpoints — third-party
# providers would reject the unknown beta header and speed parameter.
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
# output speed. Per Anthropic docs, fast mode is only supported on
# Opus 4.6 — Opus 4.7 and other models 400 on the speed parameter.
# Only for native Anthropic endpoints — third-party providers would
# reject the unknown beta header and speed parameter.
if (
fast_mode
and not _is_third_party_anthropic_endpoint(base_url)
and _supports_fast_mode(model)
):
kwargs.setdefault("extra_body", {})["speed"] = "fast"
# Build extra_headers with ALL applicable betas (the per-request
# extra_headers override the client-level anthropic-beta header).

View file

@ -1529,7 +1529,7 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
return CodexAuxiliaryClient(real_client, model), model
def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
try:
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
except ImportError:
@ -1539,10 +1539,10 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
if pool_present:
if entry is None:
return None, None
token = _pool_runtime_api_key(entry)
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
entry = None
token = resolve_anthropic_token()
token = explicit_api_key or resolve_anthropic_token()
if not token:
return None, None
@ -2336,7 +2336,7 @@ def resolve_provider_client(
if pconfig.auth_type == "api_key":
if provider == "anthropic":
client, default_model = _try_anthropic()
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
if client is None:
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
return None, None
@ -2648,8 +2648,11 @@ def resolve_vision_provider_client(
return resolved_provider, sync_client, final_model
if resolved_base_url:
provider_for_base_override = (
requested if requested and requested not in ("", "auto") else "custom"
)
client, final_model = resolve_provider_client(
"custom",
provider_for_base_override,
model=resolved_model,
async_mode=async_mode,
explicit_base_url=resolved_base_url,
@ -2657,8 +2660,8 @@ def resolve_vision_provider_client(
api_mode=resolved_api_mode,
)
if client is None:
return "custom", None, None
return "custom", client, final_model
return provider_for_base_override, None, None
return provider_for_base_override, client, final_model
if requested == "auto":
# Vision auto-detection order:

View file

@ -344,6 +344,7 @@ class ContextCompressor(ContextEngine):
self._last_aux_model_failure_model = None
self._last_compression_savings_pct = 100.0
self._ineffective_compression_count = 0
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
def update_model(
self,
@ -553,7 +554,16 @@ class ContextCompressor(ContextEngine):
break
accumulated += msg_tokens
boundary = i
prune_boundary = max(boundary, len(result) - min_protect)
# Translate the budget walk into a "protected count", apply the
# floor in count-space (where `max` reads naturally: protect at
# least `min_protect` messages or whatever the budget reserved,
# whichever is more), then convert back to a prune boundary.
# Doing this in index-space with `max` would invert the direction
# (smaller index = MORE protected), so a generous budget would
# silently get truncated back down to `min_protect`.
budget_protect_count = len(result) - boundary
protected_count = max(budget_protect_count, min_protect)
prune_boundary = len(result) - protected_count
else:
prune_boundary = len(result) - protect_tail_count
@ -569,6 +579,8 @@ class ContextCompressor(ContextEngine):
# Skip multimodal content (list of content blocks)
if isinstance(content, list):
continue
if not isinstance(content, str):
continue
if len(content) < 200:
continue
h = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:12]
@ -588,6 +600,8 @@ class ContextCompressor(ContextEngine):
# Skip multimodal content (list of content blocks)
if isinstance(content, list):
continue
if not isinstance(content, str):
continue
if not content or content == _PRUNED_TOOL_PLACEHOLDER:
continue
# Skip already-deduplicated or previously-summarized results
@ -903,15 +917,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio
or "does not exist" in _err_str
or "no available channel" in _err_str
)
_is_timeout = (
_status in (408, 429, 502, 504)
or "timeout" in _err_str
)
if (
_is_model_not_found
(_is_model_not_found or _is_timeout)
and self.summary_model
and self.summary_model != self.model
and not getattr(self, "_summary_model_fallen_back", False)
):
self._summary_model_fallen_back = True
logging.warning(
"Summary model '%s' not available (%s). "
"Summary model '%s' unavailable (%s). "
"Falling back to main model '%s' for compression.",
self.summary_model, e, self.model,
)

View file

@ -24,11 +24,12 @@ from __future__ import annotations
import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
@ -36,6 +37,22 @@ from tools import skill_usage
logger = logging.getLogger(__name__)
def _strip_aux_credential(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
class _ReviewRuntimeBinding(NamedTuple):
"""Provider/model for the curator review fork plus optional per-slot overrides."""
provider: str
model: str
explicit_api_key: Optional[str]
explicit_base_url: Optional[str]
DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
@ -453,6 +470,24 @@ def _reports_root() -> Path:
return root
def _needle_in_path_component(needle: str, path: str) -> bool:
"""Check if *needle* is a complete filename stem or directory name in *path*.
Unlike simple substring matching, this avoids false positives where short
skill names are embedded in longer filenames (e.g. "api" matching
"references/api-design.md"). Hyphens and underscores are normalised so
"open-webui-setup" matches "open_webui_setup.md".
"""
norm_needle = needle.replace("-", "_")
for part in path.replace("\\", "/").split("/"):
if not part:
continue
stem = part.rsplit(".", 1)[0] if "." in part else part
if stem.replace("-", "_") == norm_needle:
return True
return False
def _classify_removed_skills(
removed: List[str],
added: List[str],
@ -531,15 +566,29 @@ def _classify_removed_skills(
continue
# Look for the removed skill's name in file_path / content / raw.
haystacks: List[str] = []
# Matching strategy differs by field type:
# file_path — needle must be a complete path component
# (filename stem or directory name), so "api" does NOT
# falsely match "references/api-design.md".
# content fields — word-boundary regex so "test" does NOT
# falsely match "latest" or "testing".
haystacks: List[tuple[str, str]] = []
for key in ("file_path", "file_content", "content", "new_string", "_raw"):
v = args.get(key)
if isinstance(v, str):
haystacks.append(v)
haystacks.append((key, v))
hit = False
for hay in haystacks:
for key, hay in haystacks:
for needle in needles:
if needle and needle in hay:
if not needle:
continue
if key == "file_path":
matched = _needle_in_path_component(needle, hay)
else:
matched = bool(
re.search(rf'\b{re.escape(needle)}\b', hay)
)
if matched:
hit = True
evidence = (
f"skill_manage action={args.get('action', '?')} "
@ -1398,6 +1447,52 @@ def run_curator_review(
}
def _resolve_review_runtime(cfg: Dict[str, Any]) -> _ReviewRuntimeBinding:
"""Resolve provider/model and per-slot credentials for the curator review fork.
Same precedence as `_resolve_review_model()`. Non-empty ``api_key`` /
``base_url`` from the active slot are returned as explicit overrides so
``resolve_runtime_provider`` does not silently reuse the main chat
credential chain for a routed auxiliary model.
"""
_main = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
_main_provider = _main.get("provider") or "auto"
_main_model = _main.get("default") or _main.get("model") or ""
# 1. Canonical aux task slot
_aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
_cur_task = _aux.get("curator", {}) if isinstance(_aux.get("curator"), dict) else {}
_task_provider = (_cur_task.get("provider") or "").strip() or None
_task_model = (_cur_task.get("model") or "").strip() or None
if _task_provider and _task_provider != "auto" and _task_model:
return _ReviewRuntimeBinding(
_task_provider,
_task_model,
_strip_aux_credential(_cur_task.get("api_key")),
_strip_aux_credential(_cur_task.get("base_url")),
)
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
_cur = cfg.get("curator", {}) if isinstance(cfg.get("curator"), dict) else {}
_legacy = _cur.get("auxiliary", {}) if isinstance(_cur.get("auxiliary"), dict) else {}
_legacy_provider = _legacy.get("provider") or None
_legacy_model = _legacy.get("model") or None
if _legacy_provider and _legacy_model:
logger.info(
"curator: using deprecated curator.auxiliary.{provider,model} "
"config — please migrate to auxiliary.curator.{provider,model}"
)
return _ReviewRuntimeBinding(
str(_legacy_provider),
str(_legacy_model),
_strip_aux_credential(_legacy.get("api_key")),
_strip_aux_credential(_legacy.get("base_url")),
)
# 3. Fall through to the main chat model
return _ReviewRuntimeBinding(_main_provider, _main_model, None, None)
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
"""Pick (provider, model) for the curator review fork.
@ -1413,32 +1508,8 @@ def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
2. Legacy ``curator.auxiliary.{provider,model}`` when both are set
3. Main ``model.{provider,default/model}`` pair
"""
_main = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
_main_provider = _main.get("provider") or "auto"
_main_model = _main.get("default") or _main.get("model") or ""
# 1. Canonical aux task slot
_aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
_cur_task = _aux.get("curator", {}) if isinstance(_aux.get("curator"), dict) else {}
_task_provider = (_cur_task.get("provider") or "").strip() or None
_task_model = (_cur_task.get("model") or "").strip() or None
if _task_provider and _task_provider != "auto" and _task_model:
return _task_provider, _task_model
# 2. Legacy curator.auxiliary.{provider,model} (deprecated, pre-unification)
_cur = cfg.get("curator", {}) if isinstance(cfg.get("curator"), dict) else {}
_legacy = _cur.get("auxiliary", {}) if isinstance(_cur.get("auxiliary"), dict) else {}
_legacy_provider = _legacy.get("provider") or None
_legacy_model = _legacy.get("model") or None
if _legacy_provider and _legacy_model:
logger.info(
"curator: using deprecated curator.auxiliary.{provider,model} "
"config — please migrate to auxiliary.curator.{provider,model}"
)
return _legacy_provider, _legacy_model
# 3. Fall through to the main chat model
return _main_provider, _main_model
b = _resolve_review_runtime(cfg)
return b.provider, b.model
def _run_llm_review(prompt: str) -> Dict[str, Any]:
@ -1477,10 +1548,10 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
# arguments hits an auto-resolution path that fails for OAuth-only
# providers and for pool-backed credentials.
#
# `_resolve_review_model()` honors `auxiliary.curator.{provider,model}`
# `_resolve_review_runtime()` honors `auxiliary.curator.{provider,model,...}`
# (canonical aux-task slot, wired through `hermes model` → auxiliary
# picker and the dashboard Models tab), with a legacy fallback to
# `curator.auxiliary.{provider,model}`. See docs/user-guide/features/curator.md.
# `curator.auxiliary.{provider,model,...}`. See docs/user-guide/features/curator.md.
_api_key = None
_base_url = None
_api_mode = None
@ -1490,9 +1561,13 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]:
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider
_cfg = load_config()
_provider, _model_name = _resolve_review_model(_cfg)
_binding = _resolve_review_runtime(_cfg)
_provider, _model_name = _binding.provider, _binding.model
_rp = resolve_runtime_provider(
requested=_provider, target_model=_model_name
requested=_provider,
target_model=_model_name,
explicit_api_key=_binding.explicit_api_key,
explicit_base_url=_binding.explicit_base_url,
)
_api_key = _rp.get("api_key")
_base_url = _rp.get("base_url")

View file

@ -520,7 +520,12 @@ def classify_api_error(
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
if is_disconnect and not status_code:
is_large = approx_tokens > context_length * 0.6 or approx_tokens > 120000 or num_messages > 200
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have hundreds of
# messages while still being far below their actual token budget.
is_large = approx_tokens > context_length * 0.6 or (
context_length <= 256000 and (approx_tokens > 120000 or num_messages > 200)
)
if is_large:
return _result(
FailoverReason.context_overflow,
@ -766,7 +771,12 @@ def _classify_400(
if not err_body_msg:
err_body_msg = str(body.get("message") or "").strip().lower()
is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "")
is_large = approx_tokens > context_length * 0.4 or approx_tokens > 80000 or num_messages > 80
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have many messages while
# still being far below their actual token budget.
is_large = approx_tokens > context_length * 0.4 or (
context_length <= 256000 and (approx_tokens > 80000 or num_messages > 80)
)
if is_generic and is_large:
return result_fn(

View file

@ -679,7 +679,21 @@ def translate_stream_event(event: Dict[str, Any], model: str, tool_call_indices:
finish_reason_raw = str(cand.get("finishReason") or "")
if finish_reason_raw:
mapped = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(finish_reason_raw)
chunks.append(_make_stream_chunk(model=model, finish_reason=mapped))
finish_chunk = _make_stream_chunk(model=model, finish_reason=mapped)
# Attach usage from this event's usageMetadata so the streaming
# loop in run_agent.py can record token counts (mirrors the
# non-streaming path in translate_gemini_response).
usage_meta = event.get("usageMetadata") or {}
if usage_meta:
finish_chunk.usage = SimpleNamespace(
prompt_tokens=int(usage_meta.get("promptTokenCount") or 0),
completion_tokens=int(usage_meta.get("candidatesTokenCount") or 0),
total_tokens=int(usage_meta.get("totalTokenCount") or 0),
prompt_tokens_details=SimpleNamespace(
cached_tokens=int(usage_meta.get("cachedContentTokenCount") or 0),
),
)
chunks.append(finish_chunk)
return chunks

View file

@ -489,16 +489,29 @@ def save_credentials(creds: GoogleCredentials) -> Path:
"""Atomically write creds to disk with 0o600 permissions."""
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Tighten parent dir to 0o700 so siblings can't traverse to the creds file.
# On Windows this is a no-op (POSIX mode bits aren't enforced); ignore failures.
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n"
with _credentials_lock():
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
# Create with 0o600 atomically to close the TOCTOU window where the
# default umask (often 0o644) would briefly expose tokens to other
# local users between open() and chmod().
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 fh:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
atomic_replace(tmp_path, path)
finally:
try:

View file

@ -183,8 +183,8 @@ SKILLS_GUIDANCE = (
)
KANBAN_GUIDANCE = (
"# You are a Kanban worker\n"
"You were spawned by the Hermes Kanban dispatcher to execute ONE task from "
"# Kanban task execution protocol\n"
"You have been assigned ONE task from "
"the shared board at `~/.hermes/kanban.db`. Your task id is in "
"`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
"The `kanban_*` tools in your schema are your primary coordination surface — "

View file

@ -305,13 +305,18 @@ def _redact_form_body(text: str) -> str:
return _redact_query_string(text.strip())
def redact_sensitive_text(text: str, *, force: bool = False) -> str:
def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = False) -> str:
"""Apply all redaction patterns to a block of text.
Safe to call on any string -- non-matching text passes through unchanged.
Disabled by default enable via security.redact_secrets: true in config.yaml.
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
Set code_file=True to skip the ENV-assignment and JSON-field regex
patterns when the text is known to be source code (e.g. MAX_TOKENS=***
constants, "apiKey": "test" fixtures). Prefix patterns, auth headers,
private keys, DB connstrings, JWTs, and URL secrets are still redacted.
"""
if text is None:
return None
@ -325,17 +330,18 @@ def redact_sensitive_text(text: str, *, force: bool = False) -> str:
# Known prefixes (sk-, ghp_, etc.)
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
# ENV assignments: OPENAI_API_KEY=sk-abc...
def _redact_env(m):
name, quote, value = m.group(1), m.group(2), m.group(3)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
if not code_file:
def _redact_env(m):
name, quote, value = m.group(1), m.group(2), m.group(3)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# JSON fields: "apiKey": "value"
def _redact_json(m):
key, value = m.group(1), m.group(2)
return f'{key}: "{_mask_token(value)}"'
text = _JSON_FIELD_RE.sub(_redact_json, text)
# JSON fields: "apiKey": "***" (skip for code files — false positives)
def _redact_json(m):
key, value = m.group(1), m.group(2)
return f'{key}: "{_mask_token(value)}"'
text = _JSON_FIELD_RE.sub(_redact_json, text)
# Authorization headers
text = _AUTH_HEADER_RE.sub(

View file

@ -143,7 +143,18 @@ class ResponsesApiTransport(ProviderTransport):
kwargs["max_output_tokens"] = max_tokens
if is_xai_responses and session_id:
kwargs["extra_headers"] = {"x-grok-conv-id": session_id}
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["x-grok-conv-id"] = session_id
kwargs["extra_headers"] = merged_extra_headers
return kwargs

View file

@ -80,6 +80,14 @@ function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
function UnknownRouteFallback({ pluginsLoading }: { pluginsLoading: boolean }) {
if (pluginsLoading) {
// Render nothing during the plugin-load window — a spinner here would just flash.
return null;
}
return <Navigate to="/sessions" replace />;
}
const CHAT_NAV_ITEM: NavItem = {
path: "/chat",
labelKey: "chat",
@ -582,7 +590,9 @@ export default function App() {
))}
<Route
path="*"
element={<Navigate to="/sessions" replace />}
element={
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
}
/>
</Routes>

View file

@ -4,6 +4,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Typography } from "@/components/NouiTypography";
import { BUILTIN_THEMES, useTheme } from "@/themes";
import type { DashboardTheme } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
@ -11,8 +12,8 @@ import { cn } from "@/lib/utils";
* Compact theme picker mounted next to the language switcher in the header.
* Each dropdown row shows a 3-stop swatch (background / midground / warm
* glow) so users can preview the palette before committing. User-defined
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
* `BUILTIN_THEMES` render without swatches and apply the default palette.
* themes from `~/.hermes/dashboard-themes/*.yaml` use their API-provided
* definitions so they show real palette swatches just like built-ins.
*
* When placed at the bottom of a container (e.g. the sidebar rail), pass
* `dropUp` so the menu opens above the trigger instead of clipping below
@ -95,7 +96,7 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
{availableThemes.map((th) => {
const isActive = th.name === themeName;
const preset = BUILTIN_THEMES[th.name];
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
return (
<ListItem
@ -109,8 +110,8 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
}}
className="gap-3"
>
{preset ? (
<ThemeSwatch theme={preset.name} />
{paletteTheme ? (
<ThemeSwatch theme={paletteTheme} />
) : (
<PlaceholderSwatch />
)}
@ -144,10 +145,8 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
);
}
function ThemeSwatch({ theme }: { theme: string }) {
const preset = BUILTIN_THEMES[theme];
if (!preset) return <PlaceholderSwatch />;
const { background, midground, warmGlow } = preset.palette;
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
return (
<div
aria-hidden

View file

@ -27,6 +27,15 @@ import {
Wrench,
FileQuestion,
Filter,
Cloud,
Sparkles,
LayoutDashboard,
BookOpen,
Route,
History,
Shield,
FileOutput,
RefreshCw,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
@ -66,6 +75,15 @@ const CATEGORY_ICONS: Record<
logging: ClipboardList,
discord: MessageCircle,
auxiliary: Wrench,
bedrock: Cloud,
curator: Sparkles,
kanban: LayoutDashboard,
model_catalog: BookOpen,
openrouter: Route,
sessions: History,
tool_loop_guardrails: Shield,
tool_output: FileOutput,
updates: RefreshCw,
};
function CategoryIcon({

View file

@ -311,9 +311,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** All selectable themes (shown in the picker). Starts with just the
* built-ins; the API call below merges in user themes. */
const [availableThemes, setAvailableThemes] = useState<
Array<{ description: string; label: string; name: string }>
>(() =>
const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() =>
Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
@ -360,6 +358,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
name: t.name,
label: t.label,
description: t.description,
definition: t.definition,
})),
);
// Index any definitions the server shipped (user themes).
@ -430,8 +429,15 @@ const ThemeContext = createContext<ThemeContextValue>({
});
interface ThemeContextValue {
availableThemes: Array<{ description: string; label: string; name: string }>;
availableThemes: ThemeSummary[];
setTheme: (name: string) => void;
theme: DashboardTheme;
themeName: string;
}
interface ThemeSummary {
description: string;
label: string;
name: string;
definition?: DashboardTheme;
}

131
cli.py
View file

@ -459,32 +459,19 @@ def load_cli_config() -> Dict[str, Any]:
if "backend" in terminal_config:
terminal_config["env_type"] = terminal_config["backend"]
# Handle special cwd values: "." or "auto" means use current working directory.
# Only resolve to the host's CWD for the local backend where the host
# filesystem is directly accessible. For ALL remote/container backends
# (ssh, docker, modal, singularity), the host path doesn't exist on the
# target -- remove the key so terminal_tool.py uses its per-backend default.
#
# GUARD: If TERMINAL_CWD is already set to a real absolute path (by the
# gateway's config bridge earlier in the process), don't clobber it.
# This prevents a lazy import of cli.py during gateway runtime from
# rewriting TERMINAL_CWD to the service's working directory.
# See issue #10817.
# CWD resolution for CLI/TUI. The gateway has its own config bridge in
# gateway/run.py but may lazily import cli.py (triggering this code).
# Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it.
# Non-local with placeholder: pop so terminal_tool uses its per-backend default.
# Non-local with explicit path: keep as-is.
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_existing_cwd = os.environ.get("TERMINAL_CWD", "")
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
# Gateway (or earlier startup) already resolved a real path — keep it
terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = _existing_cwd
else:
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
else:
# Remove so TERMINAL_CWD stays unset → tool picks backend default
terminal_config.pop("cwd", None)
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
terminal_config.pop("cwd", None)
env_mappings = {
"env_type": "TERMINAL_ENV",
@ -517,13 +504,18 @@ def load_cli_config() -> Dict[str, Any]:
"sudo_password": "SUDO_PASSWORD",
}
# Apply config values to env vars so terminal_tool picks them up.
# If the config file explicitly has a [terminal] section, those values are
# authoritative and override any .env settings. When using defaults only
# (no config file or no terminal section), don't overwrite env vars that
# were already set by .env -- the user's .env is the fallback source.
# Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported
# UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)
# where it was already set correctly by gateway/run.py's config bridge.
_is_gateway = os.environ.get("_HERMES_GATEWAY") == "1"
for config_key, env_var in env_mappings.items():
if config_key in terminal_config:
if env_var == "TERMINAL_CWD":
if _is_gateway:
continue
# CLI: always export (overrides stale .env or inherited values)
os.environ[env_var] = str(terminal_config[config_key])
continue
if _file_has_terminal_config or env_var not in os.environ:
val = terminal_config[config_key]
if isinstance(val, list):
@ -1234,6 +1226,28 @@ def _strip_markdown_syntax(text: str) -> str:
return plain.strip("\n")
_WINDOWS_PATH_WITH_DOT_SEGMENT_RE = re.compile(
r"(?i)(?:\b[a-z]:\\|\\\\)[^\s`]*\\\.[^\s`]*"
)
def _preserve_windows_dot_segments_for_markdown(text: str) -> str:
r"""Keep Windows path separators before hidden directories in Markdown.
CommonMark treats ``\.`` as an escaped literal dot, so Rich Markdown would
render ``D:\repo\.ai`` as ``D:\repo.ai``. Doubling only that separator
inside Windows path-looking tokens preserves the path without changing
ordinary markdown escapes like ``1\. not a list``.
"""
if "\\." not in text:
return text
def _protect(match: re.Match[str]) -> str:
return re.sub(r"(?<!\\)\\(?=\.)", r"\\\\", match.group(0))
return _WINDOWS_PATH_WITH_DOT_SEGMENT_RE.sub(_protect, text)
def _render_final_assistant_content(text: str, mode: str = "render"):
"""Render final assistant content as markdown, stripped text, or raw text."""
from rich.markdown import Markdown
@ -1245,6 +1259,7 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
return _rich_text_from_ansi(text or "")
plain = _rich_text_from_ansi(text or "").plain
plain = _preserve_windows_dot_segments_for_markdown(plain)
return Markdown(plain)
@ -1513,6 +1528,10 @@ def _detect_file_drop(user_input: str) -> "dict | None":
or stripped.startswith('"~')
or stripped.startswith("'/")
or stripped.startswith("'~")
or stripped.startswith('"./')
or stripped.startswith('"../')
or stripped.startswith("'./")
or stripped.startswith("'../")
or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha())
)
if not starts_like_path:
@ -4936,7 +4955,7 @@ class HermesCLI:
except Exception:
pass
def new_session(self, silent=False):
def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
# Trigger memory extraction on the old session before session_id rotates.
@ -4991,6 +5010,28 @@ class HermesCLI:
self.agent._session_db_created = True
except Exception:
pass
if title and self._session_db:
from hermes_state import SessionDB
try:
sanitized = SessionDB.sanitize_title(title)
except ValueError as e:
_cprint(f" Title rejected: {e}")
sanitized = None
title = None
if sanitized:
try:
self._session_db.set_session_title(self.session_id, sanitized)
self._pending_title = None
title = sanitized
except ValueError as e:
_cprint(f" {e} — session started untitled.")
title = None
except Exception:
title = None
elif title is not None:
# sanitize_title returned empty (whitespace-only / unprintable)
_cprint(" Title is empty after cleanup — session started untitled.")
title = None
# Notify memory providers that session_id rotated to a fresh
# conversation. reset=True signals providers to flush accumulated
# per-session state (_session_turns, _turn_counter, _document_id).
@ -5010,7 +5051,10 @@ class HermesCLI:
self._notify_session_boundary("on_session_reset")
if not silent:
print("(^_^)v New session started!")
if title:
print(f"(^_^)v New session started: {title}")
else:
print("(^_^)v New session started!")
def _handle_resume_command(self, cmd_original: str) -> None:
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
@ -6286,7 +6330,7 @@ class HermesCLI:
_cmd_def = _resolve_cmd(_base_word)
canonical = _cmd_def.name if _cmd_def else _base_word
if canonical in ("quit", "exit", "q"):
if canonical in ("quit", "exit"):
return False
elif canonical == "help":
self.show_help()
@ -6422,7 +6466,9 @@ class HermesCLI:
else:
_cprint(" Session database not available.")
elif canonical == "new":
self.new_session()
parts = cmd_original.split(maxsplit=1)
title = parts[1].strip() if len(parts) > 1 else None
self.new_session(title=title)
elif canonical == "resume":
self._handle_resume_command(cmd_original)
elif canonical == "model":
@ -8383,6 +8429,17 @@ class HermesCLI:
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
threading.Thread(target=_restart_recording, daemon=True).start()
def _voice_speak_response_async(self, text: str) -> None:
"""Schedule TTS and mark it pending before continuous recording can restart."""
if not self._voice_tts or not text:
return
self._voice_tts_done.clear()
threading.Thread(
target=self._voice_speak_response,
args=(text,),
daemon=True,
).start()
def _voice_speak_response(self, text: str):
"""Speak the agent's response aloud using TTS (runs in background thread)."""
if not self._voice_tts:
@ -9543,11 +9600,7 @@ class HermesCLI:
# Speak response aloud if voice TTS is enabled
# Skip batch TTS when streaming TTS already handled it
if self._voice_tts and response and not use_streaming_tts:
threading.Thread(
target=self._voice_speak_response,
args=(response,),
daemon=True,
).start()
self._voice_speak_response_async(response)
# Re-queue the interrupt message (and any that arrived while we were

View file

@ -797,19 +797,36 @@ def get_due_jobs() -> List[Dict[str, Any]]:
next_run = job.get("next_run_at")
if not next_run:
schedule = job.get("schedule", {})
kind = schedule.get("kind")
# One-shot jobs use a small grace window via the dedicated helper.
recovered_next = _recoverable_oneshot_run_at(
job.get("schedule", {}),
schedule,
now,
last_run_at=job.get("last_run_at"),
)
recovery_kind = "one-shot" if recovered_next else None
# Recurring jobs reach here only when something — typically a
# direct jobs.json edit that bypassed add_job() — left
# next_run_at unset. Without this branch, such jobs are
# silently skipped forever; recompute next_run_at from the
# schedule so they pick up at their next scheduled tick.
if not recovered_next and kind in ("cron", "interval"):
recovered_next = compute_next_run(schedule, now.isoformat())
if recovered_next:
recovery_kind = kind
if not recovered_next:
continue
job["next_run_at"] = recovered_next
next_run = recovered_next
logger.info(
"Job '%s' had no next_run_at; recovering one-shot run at %s",
"Job '%s' had no next_run_at; recovering %s run at %s",
job.get("name", job["id"]),
recovery_kind,
recovered_next,
)
for rj in raw_jobs:

View file

@ -417,7 +417,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
thread_id = target.get("thread_id")
# Diagnostic: log thread_id for topic-aware delivery debugging
origin = job.get("origin") or {}
origin = _resolve_origin(job) or {}
origin_thread = origin.get("thread_id")
if origin_thread and not thread_id:
logger.warning(
@ -706,10 +706,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
f"{prompt}"
)
else:
prompt = (
"[Script ran successfully but produced no output.]\n\n"
f"{prompt}"
)
# Script produced no output — nothing to report, skip AI call.
return None
else:
prompt = (
"## Script Error\n"
@ -782,6 +780,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
return prompt
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
parts = []
skipped: list[str] = []
@ -793,6 +792,12 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
skipped.append(skill_name)
continue
# Bump usage so the curator sees this skill as actively used.
try:
bump_use(skill_name)
except Exception:
logger.debug("Cron job: failed to bump skill usage for '%s'", skill_name, exc_info=True)
content = str(loaded.get("content") or "").strip()
if parts:
parts.append("")
@ -862,6 +867,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
return True, silent_doc, SILENT_MARKER, None
prompt = _build_job_prompt(job, prerun_script=prerun_script)
if prompt is None:
logger.info("Job '%s': script produced no output, skipping AI call.", job_name)
return True, "", SILENT_MARKER, None
origin = _resolve_origin(job)
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
@ -997,8 +1005,13 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
)
from hermes_cli.auth import AuthError
try:
# Do not inject HERMES_INFERENCE_PROVIDER here. resolve_runtime_provider()
# already prefers persisted config over stale shell/env overrides when
# no explicit provider is requested. Passing the env var here short-
# circuits that precedence and can resurrect old providers (for
# example DeepSeek) for cron jobs that do not pin provider/model.
runtime_kwargs = {
"requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"),
"requested": job.get("provider"),
}
if job.get("base_url"):
runtime_kwargs["explicit_base_url"] = job.get("base_url")

View file

@ -86,6 +86,41 @@ if [ -d "$INSTALL_DIR/skills" ]; then
python3 "$INSTALL_DIR/tools/skills_sync.py"
fi
# Optionally start `hermes dashboard` as a side-process.
#
# Toggled by HERMES_DASHBOARD=1 (also accepts "true"/"yes", case-insensitive).
# Host/port/TUI can be overridden via:
# HERMES_DASHBOARD_HOST (default 0.0.0.0 — exposed outside the container)
# HERMES_DASHBOARD_PORT (default 9119, matches `hermes dashboard` default)
# HERMES_DASHBOARD_TUI (already honored by `hermes dashboard` itself)
#
# The dashboard is a long-lived server. We background it *before* the final
# `exec hermes "$@"` so the user's chosen foreground command (chat, gateway,
# sleep infinity, …) remains PID-of-interest for the container runtime. When
# the container stops the whole process tree is torn down, so no explicit
# cleanup is needed.
case "${HERMES_DASHBOARD:-}" in
1|true|TRUE|True|yes|YES|Yes)
dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}"
dash_port="${HERMES_DASHBOARD_PORT:-9119}"
dash_args=(--host "$dash_host" --port "$dash_port" --no-open)
# Binding to anything other than localhost requires --insecure — the
# dashboard refuses otherwise because it exposes API keys. Inside a
# container this is the expected deployment (host reaches it via
# published port), so opt in automatically.
if [ "$dash_host" != "127.0.0.1" ] && [ "$dash_host" != "localhost" ]; then
dash_args+=(--insecure)
fi
echo "Starting hermes dashboard on ${dash_host}:${dash_port} (background)"
# Prefix dashboard output so it's distinguishable from the main
# process in `docker logs`. stdbuf keeps the pipe line-buffered.
(
stdbuf -oL -eL hermes dashboard "${dash_args[@]}" 2>&1 \
| sed -u 's/^/[dashboard] /'
) &
;;
esac
# Final exec: two supported invocation patterns.
#
# docker run <image> -> exec `hermes` with no args (legacy default)

View file

@ -846,11 +846,25 @@ def load_gateway_config() -> GatewayConfig:
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
# Bridge top-level require_mention to Telegram when the telegram: section
# does not already provide one. Users often write "require_mention: true"
# at the top level alongside group_sessions_per_user, expecting it to work
# the same way (#3979).
_tl_require_mention = yaml_cfg.get("require_mention")
if _tl_require_mention is not None:
_tg_section = yaml_cfg.get("telegram") or {}
if "require_mention" not in _tg_section:
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
_tg_extra = _tg_plat.setdefault("extra", {})
_tg_extra.setdefault("require_mention", _tl_require_mention)
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
if isinstance(telegram_cfg, dict):
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
# Prefer telegram.require_mention; fall back to the top-level shorthand.
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
frc = telegram_cfg.get("free_response_chats")

View file

@ -62,6 +62,14 @@ MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int:
"""Parse a listen port without letting malformed env/config values crash startup."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _normalize_chat_content(
content: Any, *, _max_depth: int = 10, _depth: int = 0,
) -> str:
@ -573,7 +581,10 @@ class APIServerAdapter(BasePlatformAdapter):
super().__init__(config, Platform.API_SERVER)
extra = config.extra or {}
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
self._port: int = int(extra.get("port", os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))))
raw_port = extra.get("port")
if raw_port is None:
raw_port = os.getenv("API_SERVER_PORT", str(DEFAULT_PORT))
self._port: int = _coerce_port(raw_port, DEFAULT_PORT)
self._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
extra.get("cors_origins", os.getenv("API_SERVER_CORS_ORIGINS", "")),
@ -727,10 +738,11 @@ class APIServerAdapter(BasePlatformAdapter):
gateway platforms), falling back to the hermes-api-server default.
"""
from run_agent import AIAgent
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config, GatewayRunner
from hermes_cli.tools_config import _get_platform_tools
runtime_kwargs = _resolve_runtime_agent_kwargs()
reasoning_config = GatewayRunner._load_reasoning_config()
model = _resolve_gateway_model()
user_config = _load_gateway_config()
@ -740,7 +752,6 @@ class APIServerAdapter(BasePlatformAdapter):
# Load fallback provider chain so the API server platform has the
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
from gateway.run import GatewayRunner
fallback_model = GatewayRunner._load_fallback_model()
agent = AIAgent(
@ -759,6 +770,7 @@ class APIServerAdapter(BasePlatformAdapter):
tool_complete_callback=tool_complete_callback,
session_db=self._ensure_session_db(),
fallback_model=fallback_model,
reasoning_config=reasoning_config,
)
return agent
@ -2566,21 +2578,39 @@ class APIServerAdapter(BasePlatformAdapter):
return r, u
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
q.put_nowait({
"event": "run.completed",
"run_id": run_id,
"timestamp": time.time(),
"output": final_response,
"usage": usage,
})
self._set_run_status(
run_id,
"completed",
output=final_response,
usage=usage,
last_event="run.completed",
)
# Check for structured failure (non-retryable client errors like
# 401/400 return failed=True instead of raising, so the except
# block below never fires — issue #15561).
if isinstance(result, dict) and result.get("failed"):
error_msg = result.get("error") or "agent run failed"
q.put_nowait({
"event": "run.failed",
"run_id": run_id,
"timestamp": time.time(),
"error": error_msg,
})
self._set_run_status(
run_id,
"failed",
error=error_msg,
last_event="run.failed",
)
else:
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
q.put_nowait({
"event": "run.completed",
"run_id": run_id,
"timestamp": time.time(),
"output": final_response,
"usage": usage,
})
self._set_run_status(
run_id,
"completed",
output=final_response,
usage=usage,
last_event="run.completed",
)
except asyncio.CancelledError:
self._set_run_status(
run_id,

View file

@ -2506,7 +2506,13 @@ class BasePlatformAdapter(ABC):
_r = await self._send_with_retry(
chat_id=event.source.chat_id,
content=_text,
reply_to=event.message_id,
reply_to=(
event.reply_to_message_id
if event.source.platform == Platform.FEISHU
and event.source.thread_id
and event.reply_to_message_id
else event.message_id
),
metadata=thread_meta,
)
if _eph_ttl > 0 and _r.success and _r.message_id:
@ -2606,7 +2612,13 @@ class BasePlatformAdapter(ABC):
_r = await self._send_with_retry(
chat_id=event.source.chat_id,
content=_text,
reply_to=event.message_id,
reply_to=(
event.reply_to_message_id
if event.source.platform == Platform.FEISHU
and event.source.thread_id
and event.reply_to_message_id
else event.message_id
),
metadata=_thread_meta,
)
if _eph_ttl > 0 and _r.success and _r.message_id:
@ -2810,10 +2822,15 @@ class BasePlatformAdapter(ABC):
# Send the text portion
if text_content:
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
_reply_anchor = (
event.reply_to_message_id
if event.source.platform == Platform.FEISHU and event.source.thread_id and event.reply_to_message_id
else event.message_id
)
result = await self._send_with_retry(
chat_id=event.source.chat_id,
content=text_content,
reply_to=event.message_id,
reply_to=_reply_anchor,
metadata=_thread_metadata,
)
_record_delivery(result)

View file

@ -720,11 +720,22 @@ class DiscordAdapter(BasePlatformAdapter):
return
# If humans are mentioned but we're not → not for us
# (preserves old DISCORD_IGNORE_NO_MENTION=true behavior)
# EXCEPT in free-response channels where the bot should
# answer regardless of who is mentioned.
_ignore_no_mention = os.getenv(
"DISCORD_IGNORE_NO_MENTION", "true"
).lower() in ("true", "1", "yes")
if _ignore_no_mention and not _self_mentioned and not _other_bots_mentioned:
return
_channel_id = str(message.channel.id)
_parent_id = None
if hasattr(message.channel, "parent_id") and message.channel.parent_id:
_parent_id = str(message.channel.parent_id)
_free_channels = adapter_self._discord_free_response_channels()
_channel_ids = {_channel_id}
if _parent_id:
_channel_ids.add(_parent_id)
if "*" not in _free_channels and not (_channel_ids & _free_channels):
return
await self._handle_message(message)
@ -3797,7 +3808,7 @@ class DiscordAdapter(BasePlatformAdapter):
if not is_thread and not isinstance(message.channel, discord.DMChannel):
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel
skip_thread = bool(channel_ids & no_thread_channels)
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message:

View file

@ -2757,9 +2757,11 @@ class FeishuAdapter(BasePlatformAdapter):
if hint:
text = f"{hint}\n\n{text}" if text else hint
thread_id = getattr(message, "thread_id", None) or getattr(message, "root_id", None) or None
reply_to_message_id = (
getattr(message, "parent_id", None)
or getattr(message, "upper_message_id", None)
or getattr(message, "root_id", None)
or None
)
reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else None
@ -2791,7 +2793,7 @@ class FeishuAdapter(BasePlatformAdapter):
chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type),
user_id=sender_profile["user_id"],
user_name=sender_profile["user_name"],
thread_id=getattr(message, "thread_id", None) or None,
thread_id=thread_id,
user_id_alt=sender_profile["user_id_alt"],
is_bot=is_bot,
)
@ -4227,6 +4229,15 @@ class FeishuAdapter(BasePlatformAdapter):
if active_reply_to and not self._response_succeeded(response):
code = getattr(response, "code", None)
if code in _FEISHU_REPLY_FALLBACK_CODES:
if (metadata or {}).get("thread_id"):
logger.warning(
"[Feishu] Reply to %s failed in thread %s (code %s — message withdrawn/missing); "
"skipping top-level fallback to avoid creating a new topic",
active_reply_to,
(metadata or {}).get("thread_id"),
code,
)
return response
logger.warning(
"[Feishu] Reply to %s failed (code %s — message withdrawn/missing); "
"falling back to new message in chat %s",

View file

@ -397,13 +397,24 @@ class QQAdapter(BasePlatformAdapter):
await self._session.close()
self._session = None
self._session = aiohttp.ClientSession()
# Honor WSL proxy env for QQ WebSocket. Hermes upgrades overwrite this
# local patch, so QQ can regress to direct-connect timeouts after update.
self._session = aiohttp.ClientSession(trust_env=True)
ws_proxy = (
os.getenv("WSS_PROXY")
or os.getenv("wss_proxy")
or os.getenv("HTTPS_PROXY")
or os.getenv("https_proxy")
or os.getenv("ALL_PROXY")
or os.getenv("all_proxy")
)
self._ws = await self._session.ws_connect(
gateway_url,
headers={
"User-Agent": build_user_agent(),
},
timeout=CONNECT_TIMEOUT_SECONDS,
proxy=ws_proxy,
)
logger.info("[%s] WebSocket connected to %s", self._log_tag, gateway_url)

View file

@ -192,6 +192,15 @@ class SignalAdapter(BasePlatformAdapter):
group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "")
self.group_allow_from = set(_parse_comma_list(group_allowed_str))
# DM allowlist — mirrors SIGNAL_ALLOWED_USERS checked by run.py.
# Stored here so the reaction hooks can skip unauthorized senders
# (reactions fire before run.py's auth gate, so without this check
# every inbound DM from any contact gets a 👀 reaction).
# "*" means all users allowed (open mode); empty means no restriction
# recorded at adapter level (run.py still enforces auth separately).
dm_allowed_str = os.getenv("SIGNAL_ALLOWED_USERS", "*")
self.dm_allow_from = set(_parse_comma_list(dm_allowed_str))
# HTTP client
self.client: Optional[httpx.AsyncClient] = None
@ -1430,8 +1439,28 @@ class SignalAdapter(BasePlatformAdapter):
return None
return (author, ts)
def _reactions_enabled(self, event: "MessageEvent" = None) -> bool:
"""Check if message reactions are enabled for this event.
Two gates:
1. SIGNAL_REACTIONS env var set to false/0/no to disable globally.
2. DM allowlist if SIGNAL_ALLOWED_USERS is set, only react to
messages from senders in that list. This prevents unauthorized
contacts from seeing the 👀 reaction (which fires before run.py's
auth gate and would otherwise reveal that a bot is listening).
"""
if os.getenv("SIGNAL_REACTIONS", "true").lower() in ("false", "0", "no"):
return False
if event is not None:
sender = getattr(getattr(event, "source", None), "user_id", None)
if sender and "*" not in self.dm_allow_from and sender not in self.dm_allow_from:
return False
return True
async def on_processing_start(self, event: MessageEvent) -> None:
"""React with 👀 when processing begins."""
if not self._reactions_enabled(event):
return
target = self._extract_reaction_target(event)
if target:
await self.send_reaction(event.source.chat_id, "👀", *target)
@ -1442,6 +1471,8 @@ class SignalAdapter(BasePlatformAdapter):
On CANCELLED we leave the 👀 in place no terminal outcome means
the reaction should keep reflecting "in progress" (matches Telegram).
"""
if not self._reactions_enabled(event):
return
if outcome == ProcessingOutcome.CANCELLED:
return
target = self._extract_reaction_target(event)

View file

@ -10,7 +10,7 @@ Shares credentials with the optional telephony skill — same env vars:
Gateway-specific env vars:
- SMS_WEBHOOK_PORT (default 8080)
- SMS_WEBHOOK_HOST (default 0.0.0.0)
- SMS_WEBHOOK_HOST (default 127.0.0.1)
- SMS_WEBHOOK_URL (public URL for Twilio signature validation required)
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation dev only)
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
@ -41,7 +41,7 @@ logger = logging.getLogger(__name__)
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
DEFAULT_WEBHOOK_PORT = 8080
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
def check_sms_requirements() -> bool:
@ -91,19 +91,23 @@ class SmsAdapter(BasePlatformAdapter):
from aiohttp import web
if not self._from_number:
logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies")
msg = "[sms] TWILIO_PHONE_NUMBER not set — cannot send replies"
logger.error(msg)
self._set_fatal_error("sms_missing_phone_number", msg, retryable=False)
return False
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
if not self._webhook_url and not insecure_no_sig:
logger.error(
msg = (
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
"signature validation. Set it to the public URL configured in your "
"Twilio console (e.g. https://example.com/webhooks/twilio). "
"For local development without validation, set "
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production).",
"SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production)."
)
logger.error(msg)
self._set_fatal_error("sms_missing_webhook_url", msg, retryable=False)
return False
if insecure_no_sig and not self._webhook_url:

View file

@ -2267,13 +2267,54 @@ class TelegramAdapter(BasePlatformAdapter):
)
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
logger.error(
"[%s] Failed to send Telegram local image, falling back to base adapter: %s",
self.name,
e,
exc_info=True,
error_str = str(e)
# Dimension-related errors are the expected case for valid image
# files that Telegram just refuses as photos (screenshots, extreme
# aspect ratios). Log at INFO because the document fallback is
# the correct path. Any other send_photo failure also falls back
# to document (rate limits, corrupt file markers, format edge
# cases), but at WARNING because it's unexpected and worth
# surfacing in logs.
is_dim_error = (
"Photo_invalid_dimensions" in error_str
or "PHOTO_INVALID_DIMENSIONS" in error_str
)
return await super().send_image_file(chat_id, image_path, caption, reply_to)
if is_dim_error:
logger.info(
"[%s] Image dimensions exceed Telegram photo limits, "
"sending as document: %s",
self.name,
image_path,
)
else:
logger.warning(
"[%s] Failed to send Telegram local image as photo, "
"trying document fallback: %s",
self.name,
e,
exc_info=True,
)
# Fallback to sending as document (file) — no dimension limit,
# only 50MB size limit. If even that fails, fall back to the
# base adapter's text-only "Image: /path" rendering.
try:
return await self.send_document(
chat_id=chat_id,
file_path=image_path,
caption=caption,
file_name=os.path.basename(image_path),
reply_to=reply_to,
metadata=metadata,
)
except Exception as doc_err:
logger.error(
"[%s] Failed to send Telegram local image as document, "
"falling back to base adapter: %s",
self.name,
doc_err,
exc_info=True,
)
return await super().send_image_file(chat_id, image_path, caption, reply_to)
async def send_document(
self,

View file

@ -142,6 +142,7 @@ class WeComAdapter(BasePlatformAdapter):
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
SUPPORTS_MESSAGE_EDITING = False
# Threshold for detecting WeCom client-side message splits.
# When a chunk is near the 4000-char limit, a continuation is almost certain.
_SPLIT_THRESHOLD = 3900

View file

@ -1333,6 +1333,15 @@ class WeixinAdapter(BasePlatformAdapter):
if message_id and self._dedup.is_duplicate(message_id):
return
# Secondary content-fingerprint dedup for text messages
item_list = message.get("item_list") or []
text = _extract_text(item_list)
if text:
content_key = f"content:{sender_id}:{hashlib.md5(text.encode()).hexdigest()}"
if self._dedup.is_duplicate(content_key):
logger.debug("[%s] Content-dedup: skipping duplicate message from %s", self.name, sender_id)
return
chat_type, effective_chat_id = _guess_chat_type(message, self._account_id)
if chat_type == "group":
if self._group_policy == "disabled":
@ -1347,8 +1356,6 @@ class WeixinAdapter(BasePlatformAdapter):
self._token_store.set(self._account_id, sender_id, context_token)
asyncio.create_task(self._maybe_fetch_typing_ticket(sender_id, context_token or None))
item_list = message.get("item_list") or []
text = _extract_text(item_list)
media_paths: List[str] = []
media_types: List[str] = []

View file

@ -49,6 +49,29 @@ from hermes_cli.config import cfg_get
_AGENT_CACHE_MAX_SIZE = 128
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0
_TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(?<![\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)")
def _telegramize_command_mentions(text: str, platform: Any) -> str:
"""Rewrite slash-command mentions to Telegram-valid command names.
Telegram Bot API command names allow only lowercase letters, digits, and
underscores. Keep other platform renderings unchanged, but normalize
Telegram help text so command mentions remain clickable/valid there.
"""
platform_value = getattr(platform, "value", platform)
if platform_value != "telegram":
return text
from hermes_cli.commands import _sanitize_telegram_name
def _replace(match: re.Match[str]) -> str:
sanitized = _sanitize_telegram_name(match.group(1))
return f"/{sanitized}" if sanitized else match.group(0)
return _TELEGRAM_COMMAND_MENTION_RE.sub(_replace, text)
# Only auto-continue interrupted gateway turns while the interruption is fresh.
# Stale tool-tail/resume markers can otherwise revive an unrelated old task
# after a gateway restart when the user's next message starts new work.
@ -293,6 +316,10 @@ def _restart_notification_pending() -> bool:
return (_hermes_home / ".restart_notify.json").exists()
# Mark this process as a gateway so cli.py's module-level load_cli_config()
# knows not to clobber TERMINAL_CWD if lazily imported.
os.environ["_HERMES_GATEWAY"] = "1"
_ensure_ssl_certs()
# Add parent directory to path
@ -1161,6 +1188,10 @@ class GatewayRunner:
# Per-chat voice reply mode: "off" | "voice_only" | "all"
self._voice_mode: Dict[str, str] = self._load_voice_modes()
# Recent voice transcripts per (guild,user) for duplicate suppression.
# Protects against the same utterance being emitted twice by the voice
# capture / STT pipeline, which otherwise produces a second delayed reply.
self._recent_voice_transcripts: Dict[tuple[int, int], List[tuple[float, str]]] = {}
# Track background tasks to prevent garbage collection mid-execution
self._background_tasks: set = set()
@ -3246,6 +3277,11 @@ class GatewayRunner:
Runs in the gateway event loop; all SQLite work is pushed to a
thread via ``asyncio.to_thread`` so the loop never blocks on the
WAL lock. Failures in one tick don't stop subsequent ticks.
**Multi-board:** iterates every board discovered on disk per
tick. Subscriptions live inside each board's own DB and cannot
cross boards, so delivery semantics are unchanged this is
purely a fan-out of the single-DB poll.
"""
from gateway.config import Platform as _Platform
try:
@ -3278,40 +3314,54 @@ class GatewayRunner:
while self._running:
try:
def _collect():
conn = _kb.connect()
deliveries: list[dict] = []
# Enumerate every board on disk. Cheap: a few
# directory stat calls per tick. Missing/empty
# boards are silently skipped.
try:
_kb.init_db() # idempotent; handles first-run
boards = _kb.list_boards(include_archived=False)
except Exception:
pass
try:
subs = _kb.list_notify_subs(conn)
deliveries: list[dict] = []
for sub in subs:
cursor, events = _kb.unseen_events_for_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
kinds=TERMINAL_KINDS,
)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
deliveries.append({
"sub": sub,
"cursor": cursor,
"events": events,
"task": task,
})
return deliveries
finally:
conn.close()
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
for board_meta in boards:
slug = board_meta.get("slug") or _kb.DEFAULT_BOARD
try:
conn = _kb.connect(board=slug)
except Exception:
continue
try:
try:
_kb.init_db(board=slug) # idempotent; handles first-run
except Exception:
pass
subs = _kb.list_notify_subs(conn)
for sub in subs:
cursor, events = _kb.unseen_events_for_sub(
conn,
task_id=sub["task_id"],
platform=sub["platform"],
chat_id=sub["chat_id"],
thread_id=sub.get("thread_id") or "",
kinds=TERMINAL_KINDS,
)
if not events:
continue
task = _kb.get_task(conn, sub["task_id"])
deliveries.append({
"sub": sub,
"cursor": cursor,
"events": events,
"task": task,
"board": slug,
})
finally:
conn.close()
return deliveries
deliveries = await asyncio.to_thread(_collect)
for d in deliveries:
sub = d["sub"]
task = d["task"]
board_slug = d.get("board")
platform_str = (sub["platform"] or "").lower()
try:
plat = _Platform(platform_str)
@ -3319,7 +3369,7 @@ class GatewayRunner:
# Unknown platform string; skip and advance cursor so
# we don't replay forever.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"],
self._kanban_advance, sub, d["cursor"], board_slug,
)
continue
adapter = self.adapters.get(plat)
@ -3409,14 +3459,14 @@ class GatewayRunner:
"%s on %s after %d consecutive send failures",
sub["task_id"], platform_str, fails,
)
await asyncio.to_thread(self._kanban_unsub, sub)
await asyncio.to_thread(self._kanban_unsub, sub, board_slug)
sub_fail_counts.pop(sub_key, None)
# Don't advance cursor on send failure — retry next tick.
break
else:
# All events delivered; advance cursor + maybe unsub.
await asyncio.to_thread(
self._kanban_advance, sub, d["cursor"],
self._kanban_advance, sub, d["cursor"], board_slug,
)
# Unsubscribe when the LAST delivered event is a
# terminal kind (the task hit a "no further updates"
@ -3428,7 +3478,7 @@ class GatewayRunner:
event_terminal = last_kind in TERMINAL_EVENT_KINDS
if task_terminal or event_terminal:
await asyncio.to_thread(
self._kanban_unsub, sub,
self._kanban_unsub, sub, board_slug,
)
except Exception as exc:
logger.warning("kanban notifier tick failed: %s", exc)
@ -3438,10 +3488,16 @@ class GatewayRunner:
return
await asyncio.sleep(1)
def _kanban_advance(self, sub: dict, cursor: int) -> None:
"""Sync helper: advance a subscription's cursor. Runs in to_thread."""
def _kanban_advance(
self, sub: dict, cursor: int, board: Optional[str] = None,
) -> None:
"""Sync helper: advance a subscription's cursor. Runs in to_thread.
``board`` scopes the DB connection to the board that owns this
subscription. Unsub cursors in one board can't touch another's.
"""
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
conn = _kb.connect(board=board)
try:
_kb.advance_notify_cursor(
conn,
@ -3454,9 +3510,9 @@ class GatewayRunner:
finally:
conn.close()
def _kanban_unsub(self, sub: dict) -> None:
def _kanban_unsub(self, sub: dict, board: Optional[str] = None) -> None:
from hermes_cli import kanban_db as _kb
conn = _kb.connect()
conn = _kb.connect(board=board)
try:
_kb.remove_notify_sub(
conn,
@ -3534,20 +3590,25 @@ class GatewayRunner:
bad_ticks = 0
last_warn_at = 0
def _tick_once() -> "Optional[object]":
"""Run one dispatch_once; return result or None on error.
def _tick_once_for_board(slug: str) -> "Optional[object]":
"""Run one dispatch_once for a specific board.
Runs in a worker thread via `asyncio.to_thread`."""
Runs in a worker thread via `asyncio.to_thread`. `board=slug`
is passed through `dispatch_once` so `resolve_workspace` and
`_default_spawn` see the right paths. The per-board DB is
opened explicitly so concurrent boards never share a
connection handle or accidentally claim across each other.
"""
conn = None
try:
conn = _kb.connect()
conn = _kb.connect(board=slug)
try:
_kb.init_db() # idempotent, handles first-run
_kb.init_db(board=slug) # idempotent, handles first-run
except Exception:
pass
return _kb.dispatch_once(conn)
return _kb.dispatch_once(conn, board=slug)
except Exception:
logger.exception("kanban dispatcher: tick failed")
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
finally:
if conn is not None:
@ -3556,49 +3617,77 @@ class GatewayRunner:
except Exception:
pass
def _ready_nonempty() -> bool:
"""Cheap probe: is there at least one ready+assigned+unclaimed task?"""
conn = None
def _tick_once() -> "list[tuple[str, Optional[object]]]":
"""Run one dispatch_once per board. Returns (slug, result) pairs.
Enumerating boards on every tick keeps the dispatcher honest
when users create a new board mid-run: no restart required,
the next tick picks it up automatically.
"""
try:
conn = _kb.connect()
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
return row is not None
boards = _kb.list_boards(include_archived=False)
except Exception:
return False
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
out: list[tuple[str, "Optional[object]"]] = []
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
out.append((slug, _tick_once_for_board(slug)))
return out
def _ready_nonempty() -> bool:
"""Cheap probe: is there a ready+assigned+unclaimed task on ANY board?"""
try:
boards = _kb.list_boards(include_archived=False)
except Exception:
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
for b in boards:
slug = b.get("slug") or _kb.DEFAULT_BOARD
conn = None
try:
conn = _kb.connect(board=slug)
row = conn.execute(
"SELECT 1 FROM tasks "
"WHERE status = 'ready' AND assignee IS NOT NULL "
" AND claim_lock IS NULL LIMIT 1"
).fetchone()
if row is not None:
return True
except Exception:
continue
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
return False
logger.info(
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
)
while self._running:
try:
res = await asyncio.to_thread(_tick_once)
if res is not None and getattr(res, "spawned", None):
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher: tick spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry
results = await asyncio.to_thread(_tick_once)
any_spawned = False
for slug, res in (results or []):
if res is not None and getattr(res, "spawned", None):
any_spawned = True
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry (aggregate across boards)
ready_pending = await asyncio.to_thread(_ready_nonempty)
spawned_any = bool(res and getattr(res, "spawned", None))
if ready_pending and not spawned_any:
if ready_pending and not any_spawned:
bad_ticks += 1
else:
bad_ticks = 0
@ -5107,6 +5196,28 @@ class GatewayRunner:
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
# Expand alias quick commands before built-in dispatch so targets like
# /model openai/gpt-5.5 --provider openrouter reach the /model handler.
# Preserve built-in precedence; aliases only need early handling when
# the typed command is not already known.
if command and _cmd_def is None:
if isinstance(self.config, dict):
quick_commands = self.config.get("quick_commands", {}) or {}
else:
quick_commands = getattr(self.config, "quick_commands", {}) or {}
if isinstance(quick_commands, dict) and command in quick_commands:
qcmd = quick_commands[command]
if qcmd.get("type") == "alias":
target = qcmd.get("target", "").strip()
if target:
target = target if target.startswith("/") else f"/{target}"
target_command = target.lstrip("/")
user_args = event.get_command_args().strip()
event.text = f"{target} {user_args}".strip()
command = target_command.split()[0] if target_command else target_command
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
# Fire the ``command:<canonical>`` hook for any recognized slash
# command — built-in OR plugin-registered. Handlers can return a
# dict with ``{"decision": "deny" | "handled" | "rewrite", ...}``
@ -5320,7 +5431,7 @@ class GatewayRunner:
target_command = target.lstrip("/")
user_args = event.get_command_args().strip()
event.text = f"{target} {user_args}".strip()
command = target_command
command = target_command.split()[0] if target_command else target_command
# Fall through to normal command dispatch below
else:
return f"Quick command '/{command}' has no target defined."
@ -6681,6 +6792,7 @@ class GatewayRunner:
base_url = None
api_key = None
custom_provs = None
data = None
try:
data = _load_gateway_config()
@ -6703,6 +6815,41 @@ class GatewayRunner:
except Exception:
pass
# Also check custom_providers for context_length when top-level model.context_length is not set
if config_context_length is None and data:
try:
custom_providers = data.get("custom_providers", [])
if custom_providers:
for cp in custom_providers:
if not isinstance(cp, dict):
continue
cp_model = cp.get("model") or ""
cp_models = cp.get("models") or {}
# Match provider model to current model
if cp_model and cp_model == model:
raw_cp_ctx = cp.get("context_length")
if raw_cp_ctx is not None:
try:
config_context_length = int(raw_cp_ctx)
break
except (TypeError, ValueError):
pass
# Also check per-model context_length
if isinstance(cp_models, dict):
model_entry = cp_models.get(model)
if isinstance(model_entry, dict):
model_ctx = model_entry.get("context_length")
else:
model_ctx = model_entry
if model_ctx is not None and isinstance(model_ctx, (int, float)):
try:
config_context_length = int(model_ctx)
break
except (TypeError, ValueError):
pass
except Exception:
pass
# Resolve runtime credentials for probing
try:
runtime = _resolve_runtime_agent_kwargs()
@ -6843,6 +6990,29 @@ class GatewayRunner:
new_entry = self.session_store.get_or_create_session(source, force_new=True)
header = "✨ New session started!"
# Set session title if provided with /new <title>
_title_arg = event.get_command_args().strip()
_title_note = ""
if _title_arg and self._session_db and new_entry:
from hermes_state import SessionDB
try:
sanitized = SessionDB.sanitize_title(_title_arg)
except ValueError as e:
sanitized = None
_title_note = f"\n⚠️ Title rejected: {e}"
if sanitized:
try:
self._session_db.set_session_title(new_entry.session_id, sanitized)
header = f"✨ New session started: {sanitized}"
except ValueError as e:
_title_note = f"\n⚠️ {e} — session started untitled."
except Exception:
pass
elif not _title_note:
# sanitize_title returned empty (whitespace-only / unprintable)
_title_note = "\n⚠️ Title is empty after cleanup — session started untitled."
header = header + _title_note
# Fire plugin on_session_reset hook (new session guaranteed to exist)
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
@ -7298,7 +7468,10 @@ class GatewayRunner:
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
except Exception:
pass
return "\n".join(lines)
return _telegramize_command_mentions(
"\n".join(lines),
getattr(getattr(event, "source", None), "platform", None),
)
async def _handle_commands_command(self, event: MessageEvent) -> str:
"""Handle /commands [page] - paginated list of all commands and skills."""
@ -7351,7 +7524,10 @@ class GatewayRunner:
lines.extend(["", " | ".join(nav_parts)])
if page != requested_page:
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
return "\n".join(lines)
return _telegramize_command_mentions(
"\n".join(lines),
getattr(getattr(event, "source", None), "platform", None),
)
async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
"""Handle /model command — switch model for this session.
@ -8261,6 +8437,47 @@ class GatewayRunner:
adapter = self.adapters.get(Platform.DISCORD)
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
def _is_duplicate_voice_transcript(self, guild_id: int, user_id: int, transcript: str) -> bool:
"""Suppress repeated STT outputs for the same recent utterance.
Voice capture can occasionally emit the same utterance twice a few
seconds apart, which creates a second queued agent run and overlapping
spoken replies. Dedup exact and near-exact repeats per guild/user over a
short window while allowing genuinely new turns through.
"""
from difflib import SequenceMatcher
normalized = re.sub(r"\s+", " ", transcript).strip().lower()
normalized = re.sub(r"[^\w\s]", "", normalized)
if not normalized:
return False
now = time.monotonic()
window_seconds = 12.0
key = (guild_id, user_id)
recent_store = getattr(self, "_recent_voice_transcripts", None)
if not isinstance(recent_store, dict):
recent_store = {}
self._recent_voice_transcripts = recent_store
recent = [
(ts, txt)
for ts, txt in recent_store.get(key, [])
if now - ts <= window_seconds
]
for _, prior in recent:
if prior == normalized:
recent_store[key] = recent
return True
if len(prior) >= 16 and len(normalized) >= 16:
if SequenceMatcher(None, prior, normalized).ratio() >= 0.95:
recent_store[key] = recent
return True
recent.append((now, normalized))
recent_store[key] = recent[-5:]
return False
async def _handle_voice_channel_input(
self, guild_id: int, user_id: int, transcript: str
):
@ -8298,6 +8515,15 @@ class GatewayRunner:
logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
return
if self._is_duplicate_voice_transcript(guild_id, user_id, transcript):
logger.info(
"Suppressing duplicate voice transcript for guild=%s user=%s: %s",
guild_id,
user_id,
transcript[:100],
)
return
# Show transcript in text channel (after auth, with mention sanitization)
try:
channel = adapter._client.get_channel(text_ch_id)
@ -11311,6 +11537,12 @@ class GatewayRunner:
if not session_key:
return
pending_skills_reload_notes = getattr(
self, "_pending_skills_reload_notes", None
)
if isinstance(pending_skills_reload_notes, dict):
pending_skills_reload_notes.pop(session_key, None)
pending_approvals = getattr(self, "_pending_approvals", None)
if isinstance(pending_approvals, dict):
pending_approvals.pop(session_key, None)

View file

@ -1121,7 +1121,7 @@ class SessionStore:
self._save()
return count
def reset_session(self, session_key: str) -> Optional[SessionEntry]:
def reset_session(self, session_key: str, display_name: Optional[str] = None) -> Optional[SessionEntry]:
"""Force reset a session, creating a new session ID."""
db_end_session_id = None
db_create_kwargs = None
@ -1145,7 +1145,7 @@ class SessionStore:
created_at=now,
updated_at=now,
origin=old_entry.origin,
display_name=old_entry.display_name,
display_name=display_name if display_name is not None else old_entry.display_name,
platform=old_entry.platform,
chat_type=old_entry.chat_type,
is_fresh_reset=True,

View file

@ -5,11 +5,43 @@ Provides subcommands for:
- hermes chat - Interactive chat (same as ./hermes)
- hermes gateway - Run gateway in foreground
- hermes gateway start - Start gateway service
- hermes gateway stop - Stop gateway service
- hermes gateway stop - Stop gateway service
- hermes setup - Interactive setup wizard
- hermes status - Show status of all components
- hermes cron - Manage cron jobs
"""
import os
import sys
__version__ = "0.12.0"
__release_date__ = "2026.4.30"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
"""
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
pass
_ensure_utf8()

View file

@ -2589,6 +2589,208 @@ def _poll_for_token(
# Nous Portal — token refresh, agent key minting, model discovery
# =============================================================================
# -----------------------------------------------------------------------------
# Shared Nous token store — lets OAuth credentials persist across profiles
# so a new `hermes --profile <name> auth add nous --type oauth` can one-tap
# import instead of running the full device-code flow every time.
#
# File lives at ${HERMES_SHARED_AUTH_DIR}/nous_auth.json, defaulting to
# ~/.hermes/shared/nous_auth.json. It is OUTSIDE any named profile's
# HERMES_HOME so named profiles (which typically live under
# ~/.hermes/profiles/<name>/) all see the same file.
#
# Written on successful login and on every runtime refresh so the stored
# refresh_token stays current even if one profile refreshes and rotates it.
# If ever the stored refresh_token does go stale server-side, import fails
# gracefully and the user falls back to the normal device-code flow.
# -----------------------------------------------------------------------------
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
def _nous_shared_auth_dir() -> Path:
"""Resolve the directory that holds the shared Nous token store.
Honors ``HERMES_SHARED_AUTH_DIR`` so tests can redirect it to a tmp
path without touching the real user's home. Defaults to
``~/.hermes/shared/``.
"""
override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".hermes" / "shared"
def _nous_shared_store_path() -> Path:
path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
# Seat belt: if pytest is running and this resolves to a path under the
# real user's home, refuse rather than silently corrupt cross-profile
# state. Tests must set HERMES_SHARED_AUTH_DIR to a tmp_path (conftest
# does not do this automatically — mirror the _auth_file_path() guard
# so forgetting to set it fails loudly instead of writing to the real
# shared store).
if os.environ.get("PYTEST_CURRENT_TEST"):
real_home_shared = (
Path.home() / ".hermes" / "shared" / NOUS_SHARED_STORE_FILENAME
).resolve(strict=False)
try:
resolved = path.resolve(strict=False)
except Exception:
resolved = path
if resolved == real_home_shared:
raise RuntimeError(
f"Refusing to touch real user shared Nous auth store during test run: "
f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture."
)
return path
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
"""Persist a minimal copy of the Nous OAuth state to the shared store.
Best-effort: any failure is swallowed after logging. The shared store
is a convenience layer; the per-profile auth.json remains the source
of truth.
We deliberately omit the short-lived ``agent_key`` (24h TTL, profile-
specific) only the long-lived OAuth tokens are cross-profile useful.
"""
refresh_token = state.get("refresh_token")
access_token = state.get("access_token")
if not (isinstance(refresh_token, str) and refresh_token.strip()):
# No refresh_token = nothing worth sharing across profiles
return
if not (isinstance(access_token, str) and access_token.strip()):
return
shared = {
"_schema": 1,
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": state.get("token_type") or "Bearer",
"scope": state.get("scope") or DEFAULT_NOUS_SCOPE,
"client_id": state.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": state.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
"inference_base_url": state.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL,
"obtained_at": state.get("obtained_at"),
"expires_at": state.get("expires_at"),
"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)
_oauth_trace(
"nous_shared_store_written",
path=str(path),
refresh_token_fp=_token_fingerprint(refresh_token),
)
except Exception as exc:
logger.debug("Failed to write shared Nous auth store: %s", exc)
def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
"""Return the shared Nous OAuth state if present and well-formed.
Returns ``None`` when the file is missing, unreadable, malformed, or
lacks required fields. Callers should treat ``None`` as "no shared
credentials available fall through to device-code".
"""
try:
path = _nous_shared_store_path()
except RuntimeError:
# Test seat belt tripped — treat as missing
return None
if not path.is_file():
return None
try:
payload = json.loads(path.read_text())
except (OSError, ValueError) as exc:
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
return None
if not isinstance(payload, dict):
return None
refresh_token = payload.get("refresh_token")
access_token = payload.get("access_token")
if not (isinstance(refresh_token, str) and refresh_token.strip()):
return None
if not (isinstance(access_token, str) and access_token.strip()):
return None
return payload
def _try_import_shared_nous_state(
*,
timeout_seconds: float = 15.0,
min_key_ttl_seconds: int = 5 * 60,
) -> Optional[Dict[str, Any]]:
"""Attempt to rehydrate Nous OAuth state from the shared store.
Reads the shared file (if present), runs a forced refresh+mint using
the stored refresh_token to produce a fresh access_token + agent_key
scoped to this profile, and returns the full auth_state dict ready
for ``persist_nous_credentials()``.
Returns ``None`` when no shared state is available or the rehydrate
fails for any reason (expired refresh_token, portal unreachable,
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,
)
except AuthError as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
error_code=getattr(exc, "code", None),
)
logger.debug("Shared Nous import failed: %s", exc)
return None
except Exception as exc:
_oauth_trace(
"nous_shared_import_failed",
error_type=type(exc).__name__,
)
logger.debug("Shared Nous import failed: %s", exc)
return None
return refreshed
def _refresh_access_token(
*,
client: httpx.Client,
@ -2991,6 +3193,12 @@ def persist_nous_credentials(
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
# Mirror to the shared store so a new profile can one-tap import
# these credentials via `hermes auth add nous --type oauth`. Best-
# effort: any I/O failure is logged and swallowed (the per-profile
# auth.json is still the source of truth).
_write_shared_nous_state(state)
pool = load_pool("nous")
return next(
(e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
@ -3059,6 +3267,11 @@ def resolve_nous_runtime_credentials(
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
access_token_fp=_token_fingerprint(state.get("access_token")),
)
# Mirror post-refresh state to the shared store so sibling
# profiles don't hold stale refresh_tokens after rotation.
# Best-effort — any failure is logged and swallowed inside
# _write_shared_nous_state.
_write_shared_nous_state(state)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
@ -4283,7 +4496,8 @@ def _minimax_oauth_login(
print(f"Portal: {portal_base_url}")
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
headers={"Accept": "application/json"}) as client:
headers={"Accept": "application/json"},
follow_redirects=True) as client:
code_data = _minimax_request_user_code(
client, portal_base_url=portal_base_url,
client_id=pconfig.client_id,
@ -4360,7 +4574,8 @@ def _refresh_minimax_oauth_state(
return state
portal_base_url = state["portal_base_url"]
with httpx.Client(timeout=httpx.Timeout(timeout_seconds)) as client:
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
follow_redirects=True) as client:
response = client.post(
f"{portal_base_url}/oauth/token",
data={
@ -4598,17 +4813,47 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
)
try:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None) or pconfig.scope,
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=timeout_seconds,
insecure=insecure,
ca_bundle=ca_bundle,
min_key_ttl_seconds=5 * 60,
)
auth_state = None
# Codex-style auto-import: before launching a fresh device-code
# flow, check the shared store for an existing Nous credential
# from any other profile. If present, offer to rehydrate it.
shared = _read_shared_nous_state()
if shared:
try:
shared_path = _nous_shared_store_path()
except RuntimeError:
shared_path = None
print()
if shared_path:
print(f"Found existing Nous OAuth credentials at {shared_path}")
else:
print("Found existing shared Nous OAuth credentials")
try:
do_import = input("Import these credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in ("", "y", "yes"):
print("Rehydrating Nous session from shared credentials...")
auth_state = _try_import_shared_nous_state(
timeout_seconds=timeout_seconds,
min_key_ttl_seconds=5 * 60,
)
if auth_state is None:
print("Could not refresh shared credentials — falling back to device-code login.")
if auth_state is None:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None) or pconfig.scope,
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=timeout_seconds,
insecure=insecure,
ca_bundle=ca_bundle,
min_key_ttl_seconds=5 * 60,
)
inference_base_url = auth_state["inference_base_url"]
@ -4625,6 +4870,11 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
_save_provider_state(auth_store, "nous", auth_state)
saved_to = _save_auth_store(auth_store)
# Mirror to the shared store so other profiles can one-tap import
# these credentials. Best-effort: any I/O failure is logged and
# swallowed inside the helper.
_write_shared_nous_state(auth_state)
print()
print("Login successful!")
print(f" Auth state: {saved_to}")

View file

@ -245,6 +245,47 @@ def auth_add_command(args) -> None:
return
if provider == "nous":
# Codex-style auto-import: if a shared Nous credential lives at
# ~/.hermes/shared/nous_auth.json (written by any previous
# successful login), offer to import it instead of running the
# full device-code flow. This makes `hermes --profile <name>
# auth add nous --type oauth` a one-tap operation for users who
# run multiple profiles.
shared = auth_mod._read_shared_nous_state()
if shared:
try:
path = auth_mod._nous_shared_store_path()
except RuntimeError:
path = None
print()
if path:
print(f"Found existing Nous OAuth credentials at {path}")
else:
print("Found existing shared Nous OAuth credentials")
try:
do_import = input("Import these credentials? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
do_import = "y"
if do_import in ("", "y", "yes"):
print("Rehydrating Nous session from shared credentials...")
rehydrated = auth_mod._try_import_shared_nous_state(
timeout_seconds=getattr(args, "timeout", None) or 15.0,
min_key_ttl_seconds=max(
60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))
),
)
if rehydrated is not None:
custom_label = (getattr(args, "label", None) or "").strip() or None
entry = auth_mod.persist_nous_credentials(rehydrated, label=custom_label)
shown_label = entry.label if entry is not None else label_from_token(
rehydrated.get("access_token", ""), _oauth_default_label(provider, 1),
)
print(f'Imported {provider} OAuth credentials: "{shown_label}"')
return
# Rehydrate failed (expired refresh_token, portal down, etc.)
# — fall through to device-code flow.
print("Could not refresh shared credentials — falling back to device-code login.")
creds = auth_mod._nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),

View file

@ -61,6 +61,9 @@ _EXCLUDED_NAMES = {
"cron.pid",
}
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
def _should_exclude(rel_path: Path) -> bool:
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
@ -381,6 +384,8 @@ def run_import(args) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
if target.name in _SECRET_FILE_NAMES:
os.chmod(target, 0o600)
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f" {rel}: {exc}")
@ -788,9 +793,17 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
Returns the number of files deleted. Only touches files matching
``pre-update-*.zip`` so hand-made zips dropped in the same directory
are never touched.
``keep`` is floored to 1 because this helper is only called immediately
after a fresh backup is written: deleting that backup right after the
user paid the disk/CPU cost to create it would leave them worse off
than no backup at all (and the wrapper in ``main.py`` would still print
a misleading ``Saved: <path>`` line for a file that no longer exists).
Operators who genuinely don't want a backup should set
``updates.pre_update_backup: false`` in config that gates creation.
"""
if keep < 0:
keep = 0
if keep < 1:
keep = 1
if not backup_dir.exists():
return 0

View file

@ -64,7 +64,7 @@ class CommandDef:
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
aliases=("reset",)),
aliases=("reset",), args_hint="[name]"),
CommandDef("clear", "Clear screen and start a new session", "Session",
cli_only=True),
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
@ -399,6 +399,11 @@ def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = N
return False
def _requires_argument(args_hint: str) -> bool:
"""Return True when selecting a command without text would be incomplete."""
return args_hint.strip().startswith("<")
def gateway_help_lines() -> list[str]:
"""Generate gateway help text lines from the registry."""
overrides = _resolve_config_gates()
@ -455,7 +460,9 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
Telegram command names cannot contain hyphens, so they are replaced with
underscores. Aliases are skipped -- Telegram shows one menu entry per
canonical command.
canonical command. Commands that require arguments are skipped because
selecting a Telegram BotCommand sends only ``/command`` and would execute
an incomplete command.
Plugin-registered slash commands are included so plugins get native
autocomplete in Telegram without touching core code.
@ -465,10 +472,14 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):
continue
if _requires_argument(cmd.args_hint):
continue
tg_name = _sanitize_telegram_name(cmd.name)
if tg_name:
result.append((tg_name, cmd.description))
for name, description, _args_hint in _iter_plugin_command_entries():
for name, description, args_hint in _iter_plugin_command_entries():
if _requires_argument(args_hint):
continue
tg_name = _sanitize_telegram_name(name)
if tg_name:
result.append((tg_name, description))
@ -1115,6 +1126,12 @@ class SlashCommandCompleter(Completer):
except Exception:
return {}
# Commands that open pickers when run without arguments.
# These should NOT receive a trailing space in completions because:
# - The TUI's submit handler applies completions on Enter if input differs
# - Adding space makes "/model" → "/model " which blocks picker execution
_PICKER_COMMANDS = frozenset({"model", "skin", "personality"})
@staticmethod
def _completion_text(cmd_name: str, word: str) -> str:
"""Return replacement text for a completion.
@ -1123,8 +1140,17 @@ class SlashCommandCompleter(Completer):
returning ``help`` would be a no-op and prompt_toolkit suppresses the
menu. Appending a trailing space keeps the dropdown visible and makes
backspacing retrigger it naturally.
However, commands that open pickers (model, skin, personality) should
NOT get a trailing space the TUI would apply the completion on Enter
and block the picker from opening.
"""
return f"{cmd_name} " if cmd_name == word else cmd_name
if cmd_name != word:
return cmd_name
# Don't add space for picker commands — allows Enter to execute them
if cmd_name in SlashCommandCompleter._PICKER_COMMANDS:
return cmd_name
return f"{cmd_name} "
@staticmethod
def _extract_path_word(text: str) -> str | None:

View file

@ -1292,7 +1292,10 @@ DEFAULT_CONFIG = {
# for a single update run.
"pre_update_backup": False,
# How many pre-update backup zips to retain. Older ones are pruned
# automatically after each successful backup.
# automatically after each successful backup. Values below 1 are
# floored to 1 — the backup just created is always preserved. To
# disable backups entirely, set ``pre_update_backup: false`` above
# rather than ``backup_keep: 0``.
"backup_keep": 5,
},
@ -4682,7 +4685,9 @@ def set_config_value(key: str, value: str):
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.cwd": "TERMINAL_CWD",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",

View file

@ -156,6 +156,8 @@ def curses_checklist(
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
@ -278,6 +280,8 @@ def curses_radiolist(
flush_stdin()
return result_holder[0] if result_holder[0] is not None else cancel_returns
except KeyboardInterrupt:
return cancel_returns
except Exception:
return _radio_numbered_fallback(title, items, selected, cancel_returns)
@ -401,6 +405,8 @@ def curses_single_select(
return None
return result_holder[0]
except KeyboardInterrupt:
return None
except Exception:
all_items = list(items) + [cancel_label]
cancel_idx = len(items)

View file

@ -1,12 +1,19 @@
"""``hermes debug`` debug tools for Hermes Agent.
"""``hermes debug`` debug tools for Hermes Agent.
Currently supports:
hermes debug share Upload debug report (system info + logs) to a
paste service and print a shareable URL.
By default, log content is run through
``agent.redact.redact_sensitive_text`` with
``force=True`` before upload so credentials in
``~/.hermes/logs/*.log`` are not leaked into
the public paste service. Pass ``--no-redact``
to disable.
"""
import io
import json
import logging
import sys
import time
import urllib.error
@ -19,6 +26,16 @@ from typing import Optional
from hermes_constants import get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
# Banner prepended to upload-bound log content when redaction is enabled.
# Visible in the public paste so reviewers know the content was sanitized.
# Kept short; the trailing newline guarantees the banner sits on its own line.
_REDACTION_BANNER = (
"[hermes debug share: log content redacted at upload time. "
"run with --no-redact to disable]\n"
)
# ---------------------------------------------------------------------------
# Paste services — try paste.rs first, dpaste.com as fallback.
@ -368,17 +385,40 @@ def _resolve_log_path(log_name: str) -> Optional[Path]:
return None
def _redact_log_text(text: str) -> str:
"""Run ``redact_sensitive_text`` with ``force=True`` over upload-bound text.
Uses ``force=True`` so redaction fires regardless of the operator's
``security.redact_secrets`` setting. The local on-disk log file is
not modified; only the in-memory copy headed for the public paste
service is sanitized. Returns the redacted text (or the original
when empty / non-string).
"""
if not text:
return text
from agent.redact import redact_sensitive_text
return redact_sensitive_text(text, force=True)
def _capture_log_snapshot(
log_name: str,
*,
tail_lines: int,
max_bytes: int = _MAX_LOG_BYTES,
redact: bool = True,
) -> LogSnapshot:
"""Capture a log once and derive summary/full-log views from it.
The report tail and standalone log upload must come from the same file
snapshot. Otherwise a rotation/truncate between reads can make the report
look newer than the uploaded ``agent.log`` paste.
When ``redact`` is True (the default), both ``tail_text`` and
``full_text`` are run through ``_redact_log_text`` so the snapshot
returned is upload-safe. The on-disk log file is never modified.
Pass ``redact=False`` to capture original log content (used by
``hermes debug share --no-redact``).
"""
log_path = _resolve_log_path(log_name)
if log_path is None:
@ -438,18 +478,34 @@ def _capture_log_snapshot(
if truncated:
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
if redact:
tail_text = _redact_log_text(tail_text)
full_text = _redact_log_text(full_text)
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
except Exception as exc:
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
def _capture_default_log_snapshots(log_lines: int) -> dict[str, LogSnapshot]:
"""Capture all logs used by debug-share exactly once."""
def _capture_default_log_snapshots(
log_lines: int, *, redact: bool = True
) -> dict[str, LogSnapshot]:
"""Capture all logs used by debug-share exactly once.
``redact`` is forwarded to each ``_capture_log_snapshot`` call so all
captured logs share the same redaction policy for a given run.
"""
errors_lines = min(log_lines, 100)
return {
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
"agent": _capture_log_snapshot(
"agent", tail_lines=log_lines, redact=redact
),
"errors": _capture_log_snapshot(
"errors", tail_lines=errors_lines, redact=redact
),
"gateway": _capture_log_snapshot(
"gateway", tail_lines=errors_lines, redact=redact
),
}
@ -532,6 +588,7 @@ def run_debug_share(args):
log_lines = getattr(args, "lines", 200)
expiry = getattr(args, "expire", 7)
local_only = getattr(args, "local", False)
redact = not getattr(args, "no_redact", False)
if not local_only:
print(_PRIVACY_NOTICE)
@ -539,8 +596,16 @@ def run_debug_share(args):
print("Collecting debug report...")
# Capture dump once — prepended to every paste for context.
# The dump is already redacted at extract time via dump.py:_redact;
# log_snapshots are redacted by _capture_default_log_snapshots when
# redact=True so credentials never reach the public paste service.
dump_text = _capture_dump()
log_snapshots = _capture_default_log_snapshots(log_lines)
log_snapshots = _capture_default_log_snapshots(log_lines, redact=redact)
if redact:
logger.info(
"hermes debug share: applied force-mode redaction to log snapshots before upload"
)
report = collect_debug_report(
log_lines=log_lines,
@ -556,6 +621,15 @@ def run_debug_share(args):
if gateway_log:
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log
# Visible banner so reviewers reading the public paste know redaction
# was applied at upload time. Banner is omitted under --no-redact.
if redact:
report = _REDACTION_BANNER + report
if agent_log:
agent_log = _REDACTION_BANNER + agent_log
if gateway_log:
gateway_log = _REDACTION_BANNER + gateway_log
if local_only:
print(report)
if agent_log:
@ -666,6 +740,7 @@ def run_debug(args):
print(" --lines N Number of log lines to include (default: 200)")
print(" --expire N Paste expiry in days (default: 7)")
print(" --local Print report locally instead of uploading")
print(" --no-redact Disable upload-time secret redaction (default: redact)")
print()
print("Options (delete):")
print(" <url> ... One or more paste URLs to delete")

View file

@ -935,6 +935,8 @@ def run_doctor(args):
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
if agent_browser_path.exists():
check_ok("agent-browser (Node.js)", "(browser automation)")
elif shutil.which("agent-browser"):
check_ok("agent-browser", "(browser automation)")
else:
if _is_termux():
check_info("agent-browser is not installed (expected in the tested Termux path)")
@ -1096,9 +1098,10 @@ def run_doctor(args):
("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
# MiniMax: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.
# MiniMax global: /v1 endpoint supports /models.
("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True),
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", True),
# MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False),
("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True),
("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True),

View file

@ -237,6 +237,26 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
return False
def _get_ancestor_pids() -> set[int]:
"""Return the set of PIDs in the current process's ancestor chain.
Walks from the current PID up to PID 1 (init) so that process-table scans
never match the calling CLI process or any of its parents. This prevents
``hermes gateway status`` from falsely counting the ``hermes`` CLI that
invoked it as a running gateway instance (see #13242).
"""
ancestors: set[int] = set()
pid = os.getpid()
# Cap iterations to avoid infinite loops on exotic platforms.
for _ in range(64):
ancestors.add(pid)
parent = _get_parent_pid(pid)
if parent is None or parent <= 0 or parent in ancestors:
break
pid = parent
return ancestors
def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None:
if pid is None or pid <= 0:
return
@ -252,6 +272,10 @@ def _scan_gateway_pids(exclude_pids: set[int], all_profiles: bool = False) -> li
a live gateway when the PID file is stale/missing, and ``--all`` sweeps can
discover gateways outside the current profile.
"""
# Exclude the entire ancestor chain so the CLI process that invoked this
# scan (e.g. ``hermes gateway status``) is never mistaken for a running
# gateway. See #13242.
exclude_pids = exclude_pids | _get_ancestor_pids()
pids: list[int] = []
patterns = [
"hermes_cli.main gateway",
@ -690,6 +714,32 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:
print(" can refuse to start another copy until this process stops.")
def _print_other_profiles_gateway_status() -> None:
"""Print a summary of gateway status across all profiles.
Shown at the bottom of ``hermes gateway status`` output so users with
multiple profiles can tell at a glance which gateways are running and
avoid confusing another profile's process with the current one.
"""
try:
from hermes_cli.profiles import get_active_profile_name
current = get_active_profile_name()
other_processes = [
p for p in find_profile_gateway_processes()
if p.profile != current
]
if not other_processes:
return
print()
print("Other profiles:")
for proc in other_processes:
print(f"{proc.profile:<16s} — PID {proc.pid}")
except Exception:
pass
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.
@ -1921,6 +1971,15 @@ def systemd_uninstall(system: bool = False):
print(f"{_service_scope_label(system).capitalize()} service uninstalled")
def _require_service_installed(action: str, system: bool = False) -> None:
unit_path = get_systemd_unit_path(system=system)
if not unit_path.exists():
scope_flag = " --system" if system else ""
print(f"✗ Gateway service is not installed")
print(f" Run: {'sudo ' if system else ''}hermes gateway install{scope_flag}")
sys.exit(1)
def systemd_start(system: bool = False):
system = _select_systemd_scope(system)
if system:
@ -1930,6 +1989,7 @@ def systemd_start(system: bool = False):
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
# Raises UserSystemdUnavailableError with a remediation message.
_preflight_user_systemd()
_require_service_installed("start", system=system)
refresh_systemd_unit_if_needed(system=system)
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
print(f"{_service_scope_label(system).capitalize()} service started")
@ -1940,6 +2000,7 @@ def systemd_stop(system: bool = False):
system = _select_systemd_scope(system)
if system:
_require_root_for_system_service("stop")
_require_service_installed("stop", system=system)
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
print(f"{_service_scope_label(system).capitalize()} service stopped")
@ -1951,6 +2012,7 @@ def systemd_restart(system: bool = False):
_require_root_for_system_service("restart")
else:
_preflight_user_systemd()
_require_service_installed("restart", system=system)
refresh_systemd_unit_if_needed(system=system)
from gateway.status import get_running_pid
@ -2442,6 +2504,20 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
hasn't fully exited yet.
"""
sys.path.insert(0, str(PROJECT_ROOT))
# Refresh the systemd unit definition on every boot so that restart
# settings (RestartSec, StartLimitIntervalSec, etc.) stay current even
# when the process was respawned via exit-code-75 (stale-code or
# /restart) rather than through `hermes gateway restart` which already
# calls refresh_systemd_unit_if_needed(). Without this, a code update
# that ships new unit settings won't take effect until the next manual
# `hermes gateway start/restart` — leaving the gateway vulnerable to
# the exact failure mode the new settings were meant to prevent.
if supports_systemd_services():
try:
refresh_systemd_unit_if_needed(system=False)
except Exception:
pass # best-effort; don't block gateway startup
from gateway.run import start_gateway
@ -4456,6 +4532,9 @@ def _gateway_command_inner(args):
print(" hermes gateway install # Install as user service")
print(" sudo hermes gateway install --system # Install as boot-time system service")
# Show other profiles' gateway status for multi-profile awareness
_print_other_profiles_gateway_status()
elif subcmd == "migrate-legacy":
# Stop, disable, and remove legacy Hermes gateway unit files from
# pre-rename installs (e.g. hermes.service). Profile units and

View file

@ -169,11 +169,93 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
"or docs/hermes-kanban-v1-spec.pdf for the full design."
),
)
# --- global --board flag ---
# Applies to every subcommand below. When set, scopes all reads and
# writes to that board's DB. When omitted, resolves via the
# HERMES_KANBAN_BOARD env var, then the persisted current-board
# file, then "default". See kanban_db.get_current_board().
kanban_parser.add_argument(
"--board",
default=None,
metavar="<slug>",
help=(
"Board slug to operate on. Defaults to the current board "
"(set via `hermes kanban boards switch <slug>` or the "
"HERMES_KANBAN_BOARD env var). Use `hermes kanban boards list` "
"to see all boards."
),
)
sub = kanban_parser.add_subparsers(dest="kanban_action")
# --- init ---
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
# --- boards (new in v2: multi-project support) ---
p_boards = sub.add_parser(
"boards",
help="Manage kanban boards (one board per project / workstream)",
description=(
"Boards let you separate unrelated streams of work "
"(projects, repos, domains) into isolated queues. Each "
"board has its own DB, workspaces directory, and dispatcher "
"loop — tasks on one board cannot collide with tasks on "
"another. The first board is 'default' and always exists."
),
)
boards_sub = p_boards.add_subparsers(dest="boards_action")
b_list = boards_sub.add_parser(
"list", aliases=["ls"],
help="List all boards with task counts",
)
b_list.add_argument("--json", action="store_true")
b_list.add_argument("--all", action="store_true",
help="Include archived boards too")
b_create = boards_sub.add_parser(
"create", aliases=["new"],
help="Create a new board",
)
b_create.add_argument("slug",
help="Board slug (kebab-case, e.g. atm10-server)")
b_create.add_argument("--name", default=None,
help="Human-readable display name (defaults to Title Case of slug)")
b_create.add_argument("--description", default=None,
help="Optional description")
b_create.add_argument("--icon", default=None,
help="Optional emoji or single-character icon for the dashboard")
b_create.add_argument("--color", default=None,
help="Optional hex color (e.g. '#8b5cf6') for the dashboard")
b_create.add_argument("--switch", action="store_true",
help="Switch to the new board after creating it")
b_rm = boards_sub.add_parser(
"rm", aliases=["remove", "delete"],
help="Archive (default) or delete a board",
)
b_rm.add_argument("slug")
b_rm.add_argument("--delete", action="store_true",
help="Hard-delete the board directory instead of archiving it. "
"Default is to move it to boards/_archived/ so it's recoverable.")
b_switch = boards_sub.add_parser(
"switch", aliases=["use"],
help="Set the active board for subsequent CLI calls",
)
b_switch.add_argument("slug")
boards_sub.add_parser(
"show", aliases=["current"],
help="Print the currently-active board slug",
)
b_rename = boards_sub.add_parser(
"rename",
help="Change a board's human-readable display name (slug is immutable)",
)
b_rename.add_argument("slug")
b_rename.add_argument("name", help="New display name")
# --- create ---
p_create = sub.add_parser("create", help="Create a new task")
p_create.add_argument("title", help="Task title")
@ -366,7 +448,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
# --- log ---
p_log = sub.add_parser(
"log",
help="Print the worker log for a task (from $HERMES_HOME/kanban/logs/)",
help="Print the worker log for a task (from <kanban-root>/kanban/logs/)",
)
p_log.add_argument("task_id")
p_log.add_argument("--tail", type=int, default=None,
@ -442,6 +524,38 @@ def kanban_command(args: argparse.Namespace) -> int:
)
return 0
# `--board <slug>` applies to every subcommand below by way of an
# env-var pin for the duration of this call. Using HERMES_KANBAN_BOARD
# (rather than threading `board=` through 50+ kb.connect() sites)
# keeps the patch small and inherits the exact same resolution the
# dispatcher uses for workers — consistency is a feature here.
board_override = getattr(args, "board", None)
if board_override:
try:
normed = kb._normalize_board_slug(board_override)
except ValueError as exc:
print(f"kanban: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban: --board requires a slug", file=sys.stderr)
return 2
# Boards other than 'default' must already exist — typoed slugs
# would otherwise silently create an empty board.
if normed != kb.DEFAULT_BOARD and not kb.board_exists(normed):
print(
f"kanban: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
os.environ["HERMES_KANBAN_BOARD"] = normed
# Boards management doesn't touch the DB at all — dispatch early so
# fresh installs that haven't initialized any DB can still use
# `hermes kanban boards create …`.
if action == "boards":
return _dispatch_boards(args)
# Auto-initialize the DB before dispatching any subcommand. init_db
# is idempotent, so running it every invocation is cheap (one
# SELECT against sqlite_master when tables already exist) and
@ -513,6 +627,185 @@ def _profile_author() -> str:
return "user"
# ---------------------------------------------------------------------------
# Boards management (hermes kanban boards …)
# ---------------------------------------------------------------------------
def _dispatch_boards(args: argparse.Namespace) -> int:
"""Handle ``hermes kanban boards <action>``.
Boards management is deliberately separate from the task-level
commands: it operates on the filesystem (board directories,
``current`` pointer, ``board.json``), not on the per-board SQLite
DB, so a fresh HERMES_HOME that has never called ``kanban init``
can still run ``boards create`` / ``boards list``.
"""
sub = getattr(args, "boards_action", None) or "list"
if sub in ("list", "ls"):
return _cmd_boards_list(args)
if sub in ("create", "new"):
return _cmd_boards_create(args)
if sub in ("rm", "remove", "delete"):
return _cmd_boards_rm(args)
if sub in ("switch", "use"):
return _cmd_boards_switch(args)
if sub in ("show", "current"):
return _cmd_boards_show(args)
if sub == "rename":
return _cmd_boards_rename(args)
print(f"kanban boards: unknown action {sub!r}", file=sys.stderr)
return 2
def _board_task_counts(slug: str) -> dict[str, int]:
"""Return ``{status: count}`` for a board. Safe to call on an empty DB."""
try:
path = kb.kanban_db_path(board=slug)
if not path.exists():
return {}
with kb.connect(board=slug) as conn:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
).fetchall()
return {r["status"]: int(r["n"]) for r in rows}
except Exception:
return {}
def _cmd_boards_list(args: argparse.Namespace) -> int:
include_archived = bool(getattr(args, "all", False))
boards = kb.list_boards(include_archived=include_archived)
# Enrich each entry with task counts + whether it's the current board.
current = kb.get_current_board()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_task_counts(b["slug"])
b["total"] = sum(b["counts"].values())
if getattr(args, "json", False):
print(json.dumps(boards, indent=2, ensure_ascii=False))
return 0
# Human table: marker (•) for current, slug, display name, counts.
if not boards:
print("(no boards — create one with `hermes kanban boards create <slug>`)")
return 0
print(f"{'':2s} {'SLUG':24s} {'NAME':28s} COUNTS")
for b in boards:
marker = "" if b["is_current"] else " "
counts = b["counts"] or {}
counts_str = (
", ".join(f"{k}={v}" for k, v in sorted(counts.items()))
or "(empty)"
)
name = b.get("name") or ""
if b.get("archived"):
name += " [archived]"
print(f"{marker:2s} {b['slug']:24s} {name:28s} {counts_str}")
print()
print(f"Current board: {current}")
if len(boards) > 1:
print("Switch boards with `hermes kanban boards switch <slug>`.")
return 0
def _cmd_boards_create(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards create: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards create: slug is required", file=sys.stderr)
return 2
already = kb.board_exists(normed) and normed != kb.DEFAULT_BOARD
meta = kb.create_board(
normed,
name=args.name,
description=args.description,
icon=args.icon,
color=args.color,
)
verb = "already exists" if already else "created"
print(f"Board {meta['slug']!r} {verb}.")
print(f" Display name: {meta.get('name', '')}")
print(f" DB path: {meta['db_path']}")
if getattr(args, "switch", False):
kb.set_current_board(meta["slug"])
print(f" Switched to {meta['slug']!r}.")
else:
print(f" Use `hermes kanban boards switch {meta['slug']}` to make it current.")
return 0
def _cmd_boards_rm(args: argparse.Namespace) -> int:
try:
res = kb.remove_board(args.slug, archive=not getattr(args, "delete", False))
except ValueError as exc:
print(f"kanban boards rm: {exc}", file=sys.stderr)
return 1
if res["action"] == "archived":
print(f"Board {res['slug']!r} archived → {res['new_path']}")
print("Recover by moving the directory back to "
"<root>/kanban/boards/<slug>/.")
else:
print(f"Board {res['slug']!r} deleted.")
return 0
def _cmd_boards_switch(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards switch: {exc}", file=sys.stderr)
return 2
if not normed:
print("kanban boards switch: slug is required", file=sys.stderr)
return 2
if not kb.board_exists(normed):
print(
f"kanban boards switch: board {normed!r} does not exist. "
f"Create it with `hermes kanban boards create {normed}`.",
file=sys.stderr,
)
return 1
kb.set_current_board(normed)
print(f"Active board is now {normed!r}.")
return 0
def _cmd_boards_show(args: argparse.Namespace) -> int:
current = kb.get_current_board()
meta = kb.read_board_metadata(current)
counts = _board_task_counts(current)
total = sum(counts.values())
print(f"Current board: {current}")
print(f" Display name: {meta.get('name', '')}")
if meta.get("description"):
print(f" Description: {meta['description']}")
print(f" DB path: {meta['db_path']}")
print(f" Tasks: {total} total"
+ (f" ({', '.join(f'{k}={v}' for k, v in sorted(counts.items()))})"
if counts else ""))
return 0
def _cmd_boards_rename(args: argparse.Namespace) -> int:
try:
normed = kb._normalize_board_slug(args.slug)
except ValueError as exc:
print(f"kanban boards rename: {exc}", file=sys.stderr)
return 2
if not normed or not kb.board_exists(normed):
print(f"kanban boards rename: board {args.slug!r} does not exist",
file=sys.stderr)
return 1
meta = kb.write_board_metadata(normed, name=args.name)
print(f"Board {normed!r} renamed to {meta['name']!r}.")
return 0
# ---------------------------------------------------------------------------
def _parse_duration(val) -> Optional[int]:
"""Parse ``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds.
@ -662,6 +955,21 @@ def _cmd_list(args: argparse.Namespace) -> int:
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
return 0
# Passive discoverability: when the user has multiple boards, surface
# which one they're looking at in the list header. Single-board users
# never see this — the feature stays invisible until you opt in.
try:
all_boards = kb.list_boards(include_archived=False)
except Exception:
all_boards = []
if len(all_boards) > 1:
current = kb.get_current_board()
other_count = len(all_boards) - 1
print(
f"Board: {current} "
f"({other_count} other board{'s' if other_count != 1 else ''}"
f"`hermes kanban boards list`)\n"
)
if not tasks:
print("(no matching tasks)")
return 0

View file

@ -1,8 +1,56 @@
"""SQLite-backed Kanban board for multi-profile collaboration.
"""SQLite-backed Kanban board for multi-profile, multi-project collaboration.
The board lives at ``$HERMES_HOME/kanban.db`` (profile-agnostic on purpose:
multiple profiles on the same machine all see the same board, which IS the
coordination primitive).
In a fresh install the board lives at ``<root>/kanban.db`` where
``<root>`` is the **shared Hermes root** (the parent of any active
profile). Profiles intentionally collapse onto a shared board: it IS
the cross-profile coordination primitive. A worker spawned with
``hermes -p <profile>`` joins the same board as the dispatcher that
claimed the task. The same applies to ``<root>/kanban/workspaces/`` and
``<root>/kanban/logs/``.
**Multiple boards (projects):** users can create additional boards to
separate unrelated streams of work (e.g. one per project / repo / domain).
Each board is a directory under ``<root>/kanban/boards/<slug>/`` with
its own ``kanban.db``, ``workspaces/``, and ``logs/``. All boards share
the profile's Hermes home but are otherwise isolated: a worker spawned
for a task on board ``atm10-server`` sees only that board's tasks,
cannot enumerate other boards, and its dispatcher ticks don't touch
other boards' DBs.
The first (and for single-project users, only) board is ``default``.
For back-compat its on-disk DB is ``<root>/kanban.db`` (not
``boards/default/kanban.db``), so installs that predate the boards
feature keep working with zero migration. See :func:`kanban_db_path`.
Board resolution order (highest precedence first, all optional):
* ``board=`` argument passed directly to :func:`connect` / :func:`init_db`
(explicit used by the CLI ``--board`` flag and the dashboard
``?board=...`` query param).
* ``HERMES_KANBAN_BOARD`` env var (used by the dispatcher to pin workers
to the board their task lives on workers cannot see other boards).
* ``HERMES_KANBAN_DB`` env var (pins the DB file path directly legacy
override still honoured; highest precedence when the file path itself
is what the caller wants to force).
* ``<root>/kanban/current`` a one-line text file holding the slug of
the "currently selected" board. Written by ``hermes kanban boards
switch <slug>``. When absent, the active board is ``default``.
In standard installs ``<root>`` is ``~/.hermes``. In Docker / custom
deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g.
``/opt/hermes``), ``<root>`` is ``HERMES_HOME``. Legacy env-var
overrides still work:
* ``HERMES_KANBAN_DB`` pin the database file path directly.
* ``HERMES_KANBAN_WORKSPACES_ROOT`` pin the workspaces root directly.
* ``HERMES_KANBAN_HOME`` pin the umbrella root that anchors kanban
paths. Useful for tests and unusual deployments.
The dispatcher injects ``HERMES_KANBAN_DB``,
``HERMES_KANBAN_WORKSPACES_ROOT``, and ``HERMES_KANBAN_BOARD`` into
worker subprocess env so workers converge on the exact DB the
dispatcher used to claim their task even under unusual symlink or
Docker layouts.
Schema is intentionally small: tasks, task_links, task_comments,
task_events. The ``workspace_kind`` field decouples coordination from git
@ -15,6 +63,9 @@ transactions + compare-and-swap (CAS) updates on ``tasks.status`` and
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
most one claimer can win any given task. Losers observe zero affected
rows and move on -- no retry loops, no distributed-lock machinery.
The CAS coordination is **per-board** each board is a separate DB,
so multi-board installs get the same atomicity guarantees without any
new locking.
"""
from __future__ import annotations
@ -22,6 +73,7 @@ from __future__ import annotations
import contextlib
import json
import os
import re
import secrets
import sqlite3
import sys
@ -61,16 +113,438 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
# Paths
# ---------------------------------------------------------------------------
def kanban_db_path() -> Path:
"""Return the path to ``kanban.db`` inside the active HERMES_HOME."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban.db"
DEFAULT_BOARD = "default"
# Slug validator: lowercase alphanumerics, digits, hyphens; 164 chars.
# Strict enough to stop traversal (`..`) and embedded path separators, loose
# enough that kebab-case names like ``atm10-server`` or ``hermes-agent``
# pass without fuss. Board names with display formatting (spaces, emoji)
# live in ``board.json``; the slug is just the directory name.
_BOARD_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$")
def workspaces_root() -> Path:
"""Return the directory under which ``scratch`` workspaces are created."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban" / "workspaces"
def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
"""Lowercase + strip a slug; validate; return ``None`` for empty."""
if slug is None:
return None
s = str(slug).strip().lower()
if not s:
return None
if not _BOARD_SLUG_RE.match(s):
raise ValueError(
f"invalid board slug {slug!r}: must be 1-64 chars, lowercase "
f"alphanumerics / hyphens / underscores, not starting with '-' or '_'"
)
return s
def kanban_home() -> Path:
"""Return the shared Hermes root that anchors the kanban board.
Resolution order:
1. ``HERMES_KANBAN_HOME`` env var when set and non-empty (explicit
override for tests and unusual deployments).
2. ``get_default_hermes_root()``, which already returns ``<root>``
when ``HERMES_HOME`` is ``<root>/profiles/<name>``, and returns
``HERMES_HOME`` directly for Docker / custom deployments.
The kanban board is shared across profiles **by design** (see the
module docstring). Resolving the kanban paths through the active
profile's ``HERMES_HOME`` would silently fork the board per profile,
which breaks the dispatcher / worker handoff.
"""
override = os.environ.get("HERMES_KANBAN_HOME", "").strip()
if override:
return Path(override).expanduser()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root()
def boards_root() -> Path:
"""Return ``<root>/kanban/boards`` — the parent of non-default board dirs.
``default`` is intentionally NOT under this directory its DB lives at
``<root>/kanban.db`` for back-compat with pre-boards installs. This
function returns the directory where *additional* named boards live,
used by :func:`list_boards` to enumerate them.
"""
return kanban_home() / "kanban" / "boards"
def current_board_path() -> Path:
"""Return the path to ``<root>/kanban/current``.
One-line text file written by ``hermes kanban boards switch <slug>``
to persist the user's board selection across CLI invocations. Absent
by default (meaning: active board is ``default``).
"""
return kanban_home() / "kanban" / "current"
def get_current_board() -> str:
"""Return the active board slug, honouring the resolution chain.
Order (highest precedence first):
1. ``HERMES_KANBAN_BOARD`` env var (set by the dispatcher on worker
spawn, or manually for ad-hoc overrides).
2. ``<root>/kanban/current`` on disk (set by ``hermes kanban boards
switch``).
3. ``DEFAULT_BOARD`` (``"default"``).
A malformed slug at any step falls through to the next layer with a
best-effort warning the dispatcher must never crash because a user
hand-edited a file.
"""
env = os.environ.get("HERMES_KANBAN_BOARD", "").strip()
if env:
try:
normed = _normalize_board_slug(env)
if normed:
return normed
except ValueError:
pass
try:
f = current_board_path()
if f.exists():
val = f.read_text(encoding="utf-8").strip()
if val:
try:
normed = _normalize_board_slug(val)
if normed:
return normed
except ValueError:
pass
except OSError:
pass
return DEFAULT_BOARD
def set_current_board(slug: str) -> Path:
"""Persist ``slug`` as the active board. Returns the file written.
Writes ``<root>/kanban/current``. The caller should validate the slug
exists first (via :func:`board_exists`) this function does not
so that ``hermes kanban boards switch <typo>`` returns an error
instead of silently pointing at nothing.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
path = current_board_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(normed + "\n", encoding="utf-8")
return path
def clear_current_board() -> None:
"""Remove ``<root>/kanban/current`` so the active board reverts to ``default``."""
try:
current_board_path().unlink()
except FileNotFoundError:
pass
def board_dir(board: Optional[str] = None) -> Path:
"""Return the on-disk directory for ``board``.
``default`` is ``<root>/kanban/boards/default/`` **for metadata only**
(board.json + workspaces/ + logs/). Its DB file stays at
``<root>/kanban.db`` for back-compat see :func:`kanban_db_path`.
All other boards live at ``<root>/kanban/boards/<slug>/`` with
everything inside that directory including the ``kanban.db``.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return boards_root() / slug
def board_exists(board: Optional[str] = None) -> bool:
"""Return True if the board has a DB or a metadata dir on disk.
``default`` is considered to always exist its DB is created
on first :func:`connect` and there's no way for it to be missing
in a configuration where the kanban feature is usable at all.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
if slug == DEFAULT_BOARD:
return True
d = board_dir(slug)
return d.is_dir() or (d / "kanban.db").exists()
def kanban_db_path(board: Optional[str] = None) -> Path:
"""Return the path to the ``kanban.db`` for ``board``.
Resolution (highest precedence first):
1. ``HERMES_KANBAN_DB`` env var pins the path directly. Honoured for
back-compat and for the dispatcherworker handoff (defense in
depth: dispatcher injects this into worker env so workers are
immune to any path-resolution disagreement).
2. When ``board`` arg is None, the active board from
:func:`get_current_board` is used.
3. Board ``default`` ``<root>/kanban.db`` (back-compat path).
Other boards ``<root>/kanban/boards/<slug>/kanban.db``.
"""
override = os.environ.get("HERMES_KANBAN_DB", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban.db"
return board_dir(slug) / "kanban.db"
def workspaces_root(board: Optional[str] = None) -> Path:
"""Return the directory under which ``scratch`` workspaces are created.
Anchored per-board so workspaces don't leak between projects.
``HERMES_KANBAN_WORKSPACES_ROOT`` pins the path directly (highest
precedence) the dispatcher injects this into worker env.
``default`` keeps the legacy path ``<root>/kanban/workspaces/`` so
that existing scratch workspaces from before the boards feature are
preserved. Other boards use ``<root>/kanban/boards/<slug>/workspaces/``.
"""
override = os.environ.get("HERMES_KANBAN_WORKSPACES_ROOT", "").strip()
if override:
return Path(override).expanduser()
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "workspaces"
return board_dir(slug) / "workspaces"
def worker_logs_dir(board: Optional[str] = None) -> Path:
"""Return the directory under which per-task worker logs are written.
``default`` keeps the legacy path ``<root>/kanban/logs/``. Other
boards use ``<root>/kanban/boards/<slug>/logs/``. Logs follow the
board makes ``hermes kanban log`` unambiguous even when multiple
boards have tasks with the same id.
"""
slug = _normalize_board_slug(board)
if slug is None:
slug = get_current_board()
if slug == DEFAULT_BOARD:
return kanban_home() / "kanban" / "logs"
return board_dir(slug) / "logs"
def board_metadata_path(board: Optional[str] = None) -> Path:
"""Return the path to ``board.json`` for ``board``.
Stores display metadata (display name, description, icon, color,
created_at). The on-disk slug is the canonical identity; this file
is purely for presentation in the CLI / dashboard.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
return board_dir(slug) / "board.json"
def _default_board_display_name(slug: str) -> str:
"""Turn a slug into a reasonable default display name.
``atm10-server`` ``Atm10 Server``. Users can override via
``board.json`` but the default should look presentable in the
dashboard without any follow-up editing.
"""
return " ".join(part.capitalize() for part in slug.replace("_", "-").split("-") if part) or slug
def read_board_metadata(board: Optional[str] = None) -> dict:
"""Return ``board.json`` contents (or synthesized defaults).
Never raises a missing / malformed ``board.json`` falls back to a
synthesised entry so the dashboard always has something to render.
Includes the canonical ``slug`` and ``db_path`` so the caller
doesn't need to reconstruct them.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta: dict[str, Any] = {
"slug": slug,
"name": _default_board_display_name(slug),
"description": "",
"icon": "",
"color": "",
"created_at": None,
"archived": False,
}
try:
p = board_metadata_path(slug)
if p.exists():
raw = json.loads(p.read_text(encoding="utf-8"))
if isinstance(raw, dict):
# Never let the metadata file claim a different slug than
# its directory — trust the filesystem.
raw["slug"] = slug
meta.update(raw)
except (OSError, json.JSONDecodeError):
pass
meta["db_path"] = str(kanban_db_path(slug))
return meta
def write_board_metadata(
board: Optional[str],
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
archived: Optional[bool] = None,
) -> dict:
"""Create / update ``board.json`` for ``board``.
Preserves any existing fields not mentioned in the call. Sets
``created_at`` on first write. Returns the resulting metadata dict.
"""
slug = _normalize_board_slug(board) or DEFAULT_BOARD
meta = read_board_metadata(slug)
# Preserve existing DB-derived fields — they get re-computed each
# read but shouldn't be written into board.json.
meta.pop("db_path", None)
if name is not None:
meta["name"] = str(name).strip() or _default_board_display_name(slug)
if description is not None:
meta["description"] = str(description)
if icon is not None:
meta["icon"] = str(icon)
if color is not None:
meta["color"] = str(color)
if archived is not None:
meta["archived"] = bool(archived)
if not meta.get("created_at"):
meta["created_at"] = int(time.time())
path = board_metadata_path(slug)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(meta, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
meta["db_path"] = str(kanban_db_path(slug))
return meta
def create_board(
slug: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
icon: Optional[str] = None,
color: Optional[str] = None,
) -> dict:
"""Create a new board directory + DB + metadata. Idempotent.
Returns the resulting metadata. Raises :class:`ValueError` for a
malformed slug; returns the existing metadata (not an error) if the
board already exists matching ``mkdir -p`` semantics.
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
meta = write_board_metadata(
normed,
name=name,
description=description,
icon=icon,
color=color,
)
# Touch the DB so list_boards() sees it immediately.
init_db(board=normed)
return meta
def list_boards(*, include_archived: bool = True) -> list[dict]:
"""Enumerate all boards that exist on disk.
Always includes ``default`` (even when the ``boards/default/``
metadata dir doesn't exist, because its DB is at the legacy path).
Other boards are discovered by scanning ``boards/`` for subdirectories
that either contain a ``kanban.db`` or a ``board.json``.
Returns a list of metadata dicts, sorted with ``default`` first and
the rest alphabetically.
"""
entries: list[dict] = []
seen: set[str] = set()
# Default board is always first.
entries.append(read_board_metadata(DEFAULT_BOARD))
seen.add(DEFAULT_BOARD)
root = boards_root()
if root.is_dir():
for child in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if not child.is_dir():
continue
slug = child.name
# Keep slug normalisation soft for discovery — but skip dirs
# that don't parse as valid slugs so we don't surface junk.
try:
normed = _normalize_board_slug(slug)
except ValueError:
continue
if not normed or normed in seen:
continue
has_db = (child / "kanban.db").exists()
has_meta = (child / "board.json").exists()
if not (has_db or has_meta):
continue
meta = read_board_metadata(normed)
if meta.get("archived") and not include_archived:
continue
entries.append(meta)
seen.add(normed)
return entries
def remove_board(slug: str, *, archive: bool = True) -> dict:
"""Remove or archive a board.
``archive=True`` (default) moves the board's directory to
``<root>/kanban/boards/_archived/<slug>-<timestamp>/`` so the data
is recoverable. ``archive=False`` deletes the directory outright.
The ``default`` board cannot be removed raises :class:`ValueError`.
Returns a summary dict describing what happened (``{"slug", "action",
"new_path"}``).
"""
normed = _normalize_board_slug(slug)
if not normed:
raise ValueError("board slug is required")
if normed == DEFAULT_BOARD:
raise ValueError("the 'default' board cannot be removed")
d = board_dir(normed)
if not d.exists():
raise ValueError(f"board {normed!r} does not exist")
# If the user removed the currently-active board, revert to default.
if get_current_board() == normed:
clear_current_board()
if archive:
archive_root = boards_root() / "_archived"
archive_root.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
target = archive_root / f"{normed}-{ts}"
# Avoid collision on rapid double-archives.
suffix = 1
while target.exists():
target = archive_root / f"{normed}-{ts}-{suffix}"
suffix += 1
d.rename(target)
return {"slug": normed, "action": "archived", "new_path": str(target)}
else:
import shutil
shutil.rmtree(d)
return {"slug": normed, "action": "deleted", "new_path": ""}
# ---------------------------------------------------------------------------
@ -368,7 +842,11 @@ CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_
_INITIALIZED_PATHS: set[str] = set()
def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
def connect(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> sqlite3.Connection:
"""Open (and initialize if needed) the kanban DB.
WAL mode is enabled on every connection; it's a no-op after the first
@ -378,8 +856,19 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
fresh installs and test harnesses that construct `connect()`
directly don't have to remember a separate init step. Subsequent
connections skip the schema check via a module-level path cache.
Path resolution:
* ``db_path`` explicit used as-is (legacy callers, tests).
* ``board`` explicit resolves to that board's DB.
* Neither :func:`kanban_db_path` resolves via
``HERMES_KANBAN_DB`` env ``HERMES_KANBAN_BOARD`` env
``<root>/kanban/current`` ``default``.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
needs_init = resolved not in _INITIALIZED_PATHS
@ -398,7 +887,11 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
return conn
def init_db(db_path: Optional[Path] = None) -> Path:
def init_db(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
) -> Path:
"""Create the schema if it doesn't exist; return the path used.
Kept as a public entry point so CLI ``hermes kanban init`` and the
@ -409,7 +902,10 @@ def init_db(db_path: Optional[Path] = None) -> Path:
external tools that upgrade an old DB file can call this to
force re-migration.
"""
path = db_path or kanban_db_path()
if db_path is not None:
path = db_path
else:
path = kanban_db_path(board=board)
path.parent.mkdir(parents=True, exist_ok=True)
resolved = str(path.resolve())
# Clear the cache entry so the underlying connect() re-runs the
@ -590,6 +1086,15 @@ def _claimer_id() -> str:
# Task creation / mutation
# ---------------------------------------------------------------------------
def _canonical_assignee(assignee: Optional[str]) -> Optional[str]:
"""Lowercase-assignee normalization for Kanban rows (dashboard/CLI parity)."""
if assignee is None:
return None
from hermes_cli.profiles import normalize_profile_name
return normalize_profile_name(assignee)
def create_task(
conn: sqlite3.Connection,
*,
@ -631,6 +1136,7 @@ def create_task(
(e.g. ``skills=["translation"]`` so the worker loads the
translation skill regardless of the profile's default config).
"""
assignee = _canonical_assignee(assignee)
if not title or not title.strip():
raise ValueError("title is required")
if workspace_kind not in VALID_WORKSPACE_KINDS:
@ -795,7 +1301,7 @@ def list_tasks(
params: list[Any] = []
if assignee is not None:
query += " AND assignee = ?"
params.append(assignee)
params.append(_canonical_assignee(assignee))
if status is not None:
if status not in VALID_STATUSES:
raise ValueError(f"status must be one of {sorted(VALID_STATUSES)}")
@ -819,6 +1325,7 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str])
Refuses to reassign a task that's currently running (claim_lock set).
Reassign after the current run completes if needed.
"""
profile = _canonical_assignee(profile)
with write_txn(conn):
row = conn.execute(
"SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)
@ -1513,15 +2020,18 @@ def archive_task(conn: sqlite3.Connection, task_id: str) -> bool:
# Workspace resolution
# ---------------------------------------------------------------------------
def resolve_workspace(task: Task) -> Path:
def resolve_workspace(task: Task, *, board: Optional[str] = None) -> Path:
"""Resolve (and create if needed) the workspace for a task.
- ``scratch``: a fresh dir under ``$HERMES_HOME/kanban/workspaces/<id>/``.
- ``scratch``: a fresh dir under ``<board-root>/workspaces/<id>/``,
where ``<board-root>`` is the active board's root. The path is the
same for the dispatcher and every profile worker, so handoff is
path-stable.
- ``dir:<path>``: the path stored in ``workspace_path``. Created
if missing. MUST be absolute relative paths are rejected to
prevent confused-deputy traversal where ``../../../tmp/attacker``
resolves against the dispatcher's CWD instead of a meaningful
root. Users who want a HERMES_HOME-relative workspace should
root. Users who want a kanban-root-relative workspace should
compute the absolute path themselves.
- ``worktree``: a git worktree at ``workspace_path``. Not created
automatically in v1 -- the kanban-worker skill documents
@ -1543,7 +2053,7 @@ def resolve_workspace(task: Task) -> Path:
f"{task.workspace_path!r}; workspace paths must be absolute"
)
else:
p = workspaces_root() / task.id
p = workspaces_root(board=board) / task.id
p.mkdir(parents=True, exist_ok=True)
return p
if kind == "dir":
@ -1957,6 +2467,7 @@ def dispatch_once(
dry_run: bool = False,
max_spawn: Optional[int] = None,
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
board: Optional[str] = None,
) -> DispatchResult:
"""Run one dispatcher tick.
@ -1965,15 +2476,17 @@ def dispatch_once(
2. Reclaim crashed running tasks (host-local PID no longer alive).
3. Promote todo -> ready where all parents are done.
4. For each ready task with an assignee, atomically claim and call
``spawn_fn(task, workspace_path) -> Optional[int]``. The return
value (if any) is recorded as ``worker_pid`` so subsequent ticks
can detect crashes before the TTL expires.
``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
return value (if any) is recorded as ``worker_pid`` so subsequent
ticks can detect crashes before the TTL expires.
Spawn failures are counted per-task. After ``failure_limit`` consecutive
failures the task is auto-blocked with the last error as its reason
prevents the dispatcher from thrashing forever on an unfixable task.
``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
``board`` pins workspace/log/db resolution for this tick to a specific
board. When omitted, the current-board resolution chain is used.
"""
result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
@ -2000,7 +2513,7 @@ def dispatch_once(
if claimed is None:
continue
try:
workspace = resolve_workspace(claimed)
workspace = resolve_workspace(claimed, board=board)
except Exception as exc:
auto = _record_spawn_failure(
conn, claimed.id, f"workspace: {exc}",
@ -2013,7 +2526,18 @@ def dispatch_once(
set_workspace_path(conn, claimed.id, str(workspace))
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
try:
pid = _spawn(claimed, str(workspace))
# Back-compat: older spawn_fn signatures accept only
# (task, workspace). Test stubs in the suite rely on that.
# Introspect the callable and pass `board` only when supported.
import inspect
try:
sig = inspect.signature(_spawn)
if "board" in sig.parameters:
pid = _spawn(claimed, str(workspace), board=board)
else:
pid = _spawn(claimed, str(workspace))
except (TypeError, ValueError):
pid = _spawn(claimed, str(workspace))
if pid:
_set_worker_pid(conn, claimed.id, int(pid))
_clear_spawn_failures(conn, claimed.id)
@ -2052,33 +2576,60 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
pass
def _default_spawn(task: Task, workspace: str) -> Optional[int]:
def _default_spawn(
task: Task,
workspace: str,
*,
board: Optional[str] = None,
) -> Optional[int]:
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
Returns the spawned child's PID so the dispatcher can detect crashes
before the claim TTL expires. The child's completion is still observed
via the ``complete`` / ``block`` transitions the worker writes itself;
the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
``board`` pins the child's kanban context to that board: the child's
``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root env
vars all resolve to the same board the dispatcher claimed the task
from. Workers cannot accidentally see other boards.
"""
import subprocess
if not task.assignee:
raise ValueError(f"task {task.id} has no assignee")
from hermes_cli.profiles import normalize_profile_name
profile_arg = normalize_profile_name(task.assignee)
prompt = f"work kanban task {task.id}"
env = dict(os.environ)
if task.tenant:
env["HERMES_TENANT"] = task.tenant
env["HERMES_KANBAN_TASK"] = task.id
env["HERMES_KANBAN_WORKSPACE"] = workspace
# Pin the shared board + workspaces root the dispatcher resolved, so
# that even when the worker activates a profile (`hermes -p <name>`
# rewrites HERMES_HOME), its kanban paths still match the
# dispatcher's. Belt-and-braces with the `get_default_hermes_root()`
# resolution in `kanban_home()` — symmetric resolution is the norm,
# but unusual symlink / Docker layouts are caught here too.
env["HERMES_KANBAN_DB"] = str(kanban_db_path(board=board))
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(workspaces_root(board=board))
# Board slug — the final defense-in-depth pin. If the worker ever
# resolves kanban paths without the DB / workspaces env vars, the
# board slug still forces it to the right directory.
resolved_board = _normalize_board_slug(board) or get_current_board()
env["HERMES_KANBAN_BOARD"] = resolved_board
# HERMES_PROFILE is the author the kanban_comment tool defaults to.
# `hermes -p <assignee>` activates the profile, but the env var is
# what the tool reads — set it explicitly here so comments are
# attributed correctly regardless of how the child loads config.
env["HERMES_PROFILE"] = task.assignee
env["HERMES_PROFILE"] = profile_arg
cmd = [
"hermes",
"-p", task.assignee,
"-p", profile_arg,
# Auto-load the kanban-worker skill so every dispatched worker
# has the pattern library (good summary/metadata shapes, retry
# diagnostics, block-reason examples) in its context, even if
@ -2104,9 +2655,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
"chat",
"-q", prompt,
])
# Redirect output to a per-task log under HERMES_HOME/kanban/logs/.
from hermes_constants import get_hermes_home
log_dir = get_hermes_home() / "kanban" / "logs"
# Redirect output to a per-task log under <board-root>/logs/.
# Anchored at the board root (not the shared kanban root), so
# `hermes kanban log` on a specific board reads its own file and
# logs don't collide across boards that happen to share task ids.
log_dir = worker_logs_dir(board=board)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"{task.id}.log"
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
@ -2587,12 +3140,14 @@ def gc_events(
def gc_worker_logs(
*, older_than_seconds: int = 30 * 24 * 3600,
board: Optional[str] = None,
) -> int:
"""Delete worker log files older than ``older_than_seconds``. Returns
the number of files removed. Kept separate from ``gc_events`` because
log files live on disk, not in SQLite."""
from hermes_constants import get_hermes_home
log_dir = get_hermes_home() / "kanban" / "logs"
log files live on disk, not in SQLite. Scoped to ``board`` (defaults
to the active board) per-board isolation means deleting logs from
board A cannot touch board B's logs."""
log_dir = worker_logs_dir(board=board)
if not log_dir.exists():
return 0
cutoff = time.time() - older_than_seconds
@ -2611,20 +3166,25 @@ def gc_worker_logs(
# Worker log accessor
# ---------------------------------------------------------------------------
def worker_log_path(task_id: str) -> Path:
def worker_log_path(task_id: str, *, board: Optional[str] = None) -> Path:
"""Return the path to a worker's log file. The file may not exist
(task never spawned, or log already GC'd)."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "kanban" / "logs" / f"{task_id}.log"
(task never spawned, or log already GC'd).
When ``board`` is None, resolves via the active board (env var
current-board file default). The dispatcher always passes the
board explicitly to avoid any resolution ambiguity when multiple
boards exist."""
return worker_logs_dir(board=board) / f"{task_id}.log"
def read_worker_log(
task_id: str, *, tail_bytes: Optional[int] = None,
board: Optional[str] = None,
) -> Optional[str]:
"""Read the worker log for ``task_id``. Returns None if the file
doesn't exist. If ``tail_bytes`` is set, only the last N bytes are
returned (useful for the dashboard drawer which shouldn't page megabytes)."""
path = worker_log_path(task_id)
path = worker_log_path(task_id, board=board)
if not path.exists():
return None
try:
@ -2661,7 +3221,8 @@ def list_profiles_on_disk() -> list[str]:
``config.yaml`` a bare dir without config isn't a real profile.
"""
try:
home = Path.home() / ".hermes" / "profiles"
from hermes_constants import get_default_hermes_root
home = get_default_hermes_root() / "profiles"
except Exception:
return []
if not home.is_dir():

View file

@ -114,6 +114,16 @@ def _apply_profile_override() -> None:
consume = 1
break
# 1b. Reject values that can't be valid profile names (e.g. pytest's
# "-p no:xdist" would be misread as profile "no:xdist" otherwise).
# Mirrors hermes_cli.profiles._PROFILE_ID_RE so we never call
# resolve_profile_env() with a value it must reject + sys.exit on.
if profile_name is not None and consume == 2:
import re as _re
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name):
profile_name = None
consume = 0
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it.
# This lets child processes (relaunch, subprocess) inherit the parent's
# profile choice without having to pass --profile again.
@ -837,7 +847,17 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
)
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert"})
_NPM_LOCK_RUNTIME_KEYS = frozenset({"ideallyInert", "peer"})
"""Lockfile fields npm writes non-deterministically at install time.
``ideallyInert`` is npm's runtime annotation for packages it skipped installing
(per-platform opt-outs). ``peer`` is dropped from the hidden ``.package-lock.json``
on dev-dependencies that are *also* declared as peers the canonical
``package-lock.json`` records the dual role, but npm 9's actualized tree strips
it. Neither key represents a real skew between what was declared and what was
installed, so we exclude them from the comparison in :func:`_tui_need_npm_install`
to avoid false-positive reinstalls on every launch.
"""
def _tui_need_npm_install(root: Path) -> bool:
@ -1042,17 +1062,21 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
if _tui_need_npm_install(tui_dir):
if not os.environ.get("HERMES_QUIET"):
print("Installing TUI dependencies…")
# Capture stdout as well as stderr — some npm errors (notably EACCES on a
# root-owned node_modules in containers) are emitted on stdout, and a
# bare "npm install failed." with no preview defeats debugging. We keep
# the failure-only print path so a successful install stays silent.
result = subprocess.run(
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
cwd=str(tui_dir),
stdout=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, "CI": "1"},
)
if result.returncode != 0:
err = (result.stderr or "").strip()
preview = "\n".join(err.splitlines()[-30:])
combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
preview = "\n".join(combined.splitlines()[-30:])
print("npm install failed.")
if preview:
print(preview)
@ -3399,10 +3423,10 @@ def _model_flow_named_custom(config, provider_info):
print()
print("Fetching available models...")
models = fetch_api_models(
api_key, base_url, timeout=8.0,
api_mode=api_mode or None,
)
fetch_kwargs = {"timeout": 8.0}
if api_mode:
fetch_kwargs["api_mode"] = api_mode
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
if models:
default_idx = 0
@ -6477,13 +6501,29 @@ def _cmd_update_check():
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
print("→ Fetching from origin...")
# Fetch both origin and upstream; prefer upstream as the canonical reference
print("→ Fetching from upstream...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
git_cmd + ["fetch", "upstream"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
if fetch_result.returncode != 0:
# Fallback to origin if upstream doesn't exist
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
)
upstream_exists = False
compare_branch = "origin/main"
else:
upstream_exists = True
compare_branch = "upstream/main"
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.strip()
if "Could not resolve host" in stderr or "unable to access" in stderr:
@ -6491,13 +6531,13 @@ def _cmd_update_check():
elif "Authentication failed" in stderr or "could not read Username" in stderr:
print("✗ Authentication failed — check your git credentials or SSH key.")
else:
print("✗ Failed to fetch from origin.")
print("✗ Failed to fetch.")
if stderr:
print(f" {stderr.splitlines()[0]}")
sys.exit(1)
rev_result = subprocess.run(
git_cmd + ["rev-list", "HEAD..origin/main", "--count"],
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
@ -6509,7 +6549,7 @@ def _cmd_update_check():
print("✓ Already up to date.")
else:
commits_word = "commit" if behind == 1 else "commits"
print(f"⚕ Update available: {behind} {commits_word} behind origin/main.")
print(f"⚕ Update available: {behind} {commits_word} behind {compare_branch}.")
from hermes_cli.config import recommended_update_command
print(f" Run '{recommended_update_command()}' to install.")
@ -8897,6 +8937,7 @@ Examples:
hermes debug share --lines 500 Include more log lines
hermes debug share --expire 30 Keep paste for 30 days
hermes debug share --local Print report locally (no upload)
hermes debug share --no-redact Disable upload-time secret redaction
hermes debug delete <url> Delete a previously uploaded paste
""",
)
@ -8922,6 +8963,16 @@ Examples:
action="store_true",
help="Print the report locally instead of uploading",
)
share_parser.add_argument(
"--no-redact",
action="store_true",
help=(
"Disable upload-time secret redaction (default: redact). Logs "
"are normally run through agent.redact.redact_sensitive_text "
"with force=True before upload so credentials are not leaked "
"into the public paste service."
),
)
delete_parser = debug_sub.add_parser(
"delete",
help="Delete a paste uploaded by 'hermes debug share'",

View file

@ -904,6 +904,26 @@ def switch_model(
if any(m.get("name") == new_model for m in cfg_models if isinstance(m, dict)):
override = True
break
# Also check custom_providers list — models declared there should be accepted
# even if the remote /v1/models endpoint doesn't list them.
if not override and custom_providers and isinstance(custom_providers, list):
for entry in custom_providers:
if not isinstance(entry, dict):
continue
# Match by provider slug (custom:<name>) or by base_url
entry_name = entry.get("name", "")
entry_slug = f"custom:{entry_name}" if entry_name else ""
entry_url = entry.get("base_url", "")
if entry_slug == target_provider or entry_url == base_url:
# Check if the requested model matches the entry's model
entry_model = entry.get("model", "")
entry_models = entry.get("models", {})
if new_model == entry_model:
override = True
break
if isinstance(entry_models, dict) and new_model in entry_models:
override = True
break
if override:
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
else:
@ -1244,11 +1264,7 @@ def list_authenticated_providers(
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
providers_store = store.get("providers", {})
pool_store = store.get("credential_pool", {})
if store and (
pid in providers_store or hermes_slug in providers_store
or pid in pool_store or hermes_slug in pool_store
):
if store and (pid in providers_store or hermes_slug in providers_store):
has_creds = True
except Exception as exc:
logger.debug("Auth store check failed for %s: %s", pid, exc)
@ -1344,11 +1360,7 @@ def list_authenticated_providers(
from hermes_cli.auth import _load_auth_store
_cp_store = _load_auth_store()
_cp_providers_store = _cp_store.get("providers", {})
_cp_pool_store = _cp_store.get("credential_pool", {})
if _cp_store and (
_cp.slug in _cp_providers_store
or _cp.slug in _cp_pool_store
):
if _cp_store and _cp.slug in _cp_providers_store:
_cp_has_creds = True
except Exception:
pass

View file

@ -1740,10 +1740,20 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode."""
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
"Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
with an unsupported model returns an error." Opus 4.7 explicitly rejects
the ``speed`` parameter with HTTP 400.
"""
raw = _strip_vendor_prefix(str(model_id or ""))
base = raw.split(":")[0]
return base.startswith("claude-")
if not base.startswith("claude-"):
return False
# Only Opus 4.6 supports fast mode at present.
return "opus-4-6" in base or "opus-4.6" in base
def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | None:
@ -3087,7 +3097,7 @@ def validate_requested_model(
"message": f"Model `{requested}` was not found in LM Studio's model listing.",
}
if normalized == "custom":
if normalized == "custom" or normalized.startswith("custom:"):
# Try probing with correct auth for the api_mode.
if api_mode == "anthropic_messages":
probe = probe_api_models(api_key, base_url, api_mode=api_mode)
@ -3185,11 +3195,12 @@ def validate_requested_model(
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
return {
"accepted": False,
"persist": False,
"accepted": True,
"persist": True,
"recognized": False,
"message": (
f"Model `{requested}` was not found in the OpenAI Codex model listing."
f"Note: `{requested}` was not found in the OpenAI Codex model listing. "
"It may still work if your ChatGPT/Codex account has access to a newer or hidden model ID."
f"{suggestion_text}"
),
}

View file

@ -179,8 +179,33 @@ def _get_wrapper_dir() -> Path:
# Validation
# ---------------------------------------------------------------------------
def normalize_profile_name(name: str) -> str:
"""Return the canonical profile id used on disk and in CLI ``-p`` argv.
Named profiles are stored lowercase under ``profiles/<id>/``. The special
alias ``default`` is matched case-insensitively (``Default`` ``default``).
Dashboards and tools may pass title-cased display labels; normalize before
validation, assignment, and subprocess spawn (see issue #18498).
"""
if not isinstance(name, str):
name = str(name)
stripped = name.strip()
if not stripped:
raise ValueError("profile name cannot be empty")
if stripped.casefold() == "default":
return "default"
return stripped.lower()
def validate_profile_name(name: str) -> None:
"""Raise ``ValueError`` if *name* is not a valid profile identifier."""
"""Raise ``ValueError`` if *name* is not a valid profile identifier.
Validates the input as-given strict lowercase match. Callers that accept
mixed-case or title-cased input from users (dashboard UI, CLI args) should
call :func:`normalize_profile_name` first. This separation keeps validate
honest about what the on-disk directory name must look like, while
ingress-point normalization handles UX flexibility (see #18498).
"""
if name == "default":
return # special alias for ~/.hermes
if not _PROFILE_ID_RE.match(name):
@ -192,16 +217,18 @@ def validate_profile_name(name: str) -> None:
def get_profile_dir(name: str) -> Path:
"""Resolve a profile name to its HERMES_HOME directory."""
if name == "default":
canon = normalize_profile_name(name)
if canon == "default":
return _get_default_hermes_home()
return _get_profiles_root() / name
return _get_profiles_root() / canon
def profile_exists(name: str) -> bool:
"""Check whether a profile directory exists."""
if name == "default":
canon = normalize_profile_name(name)
if canon == "default":
return True
return get_profile_dir(name).is_dir()
return get_profile_dir(canon).is_dir()
# ---------------------------------------------------------------------------
@ -213,28 +240,29 @@ def check_alias_collision(name: str) -> Optional[str]:
Checks: reserved names, hermes subcommands, existing binaries in PATH.
"""
if name in _RESERVED_NAMES:
return f"'{name}' is a reserved name"
if name in _HERMES_SUBCOMMANDS:
return f"'{name}' conflicts with a hermes subcommand"
canon = normalize_profile_name(name)
if canon in _RESERVED_NAMES:
return f"'{canon}' is a reserved name"
if canon in _HERMES_SUBCOMMANDS:
return f"'{canon}' conflicts with a hermes subcommand"
# Check existing commands in PATH
wrapper_dir = _get_wrapper_dir()
try:
result = subprocess.run(
["which", name], capture_output=True, text=True, timeout=5,
["which", canon], capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
existing_path = result.stdout.strip()
# Allow overwriting our own wrappers
if existing_path == str(wrapper_dir / name):
if existing_path == str(wrapper_dir / canon):
try:
content = (wrapper_dir / name).read_text()
content = (wrapper_dir / canon).read_text()
if "hermes -p" in content:
return None # it's our wrapper, safe to overwrite
except Exception:
pass
return f"'{name}' conflicts with an existing command ({existing_path})"
return f"'{canon}' conflicts with an existing command ({existing_path})"
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@ -252,6 +280,7 @@ def create_wrapper_script(name: str) -> Optional[Path]:
Returns the path to the created wrapper, or None if creation failed.
"""
canon = normalize_profile_name(name)
wrapper_dir = _get_wrapper_dir()
try:
wrapper_dir.mkdir(parents=True, exist_ok=True)
@ -259,9 +288,9 @@ def create_wrapper_script(name: str) -> Optional[Path]:
print(f"⚠ Could not create {wrapper_dir}: {e}")
return None
wrapper_path = wrapper_dir / name
wrapper_path = wrapper_dir / canon
try:
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
return wrapper_path
except OSError as e:
@ -271,7 +300,7 @@ def create_wrapper_script(name: str) -> Optional[Path]:
def remove_wrapper_script(name: str) -> bool:
"""Remove the wrapper script for a profile. Returns True if removed."""
wrapper_path = _get_wrapper_dir() / name
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
if wrapper_path.exists():
try:
# Verify it's our wrapper before removing
@ -421,16 +450,17 @@ def create_profile(
Path
The newly created profile directory.
"""
validate_profile_name(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
if name == "default":
if canon == "default":
raise ValueError(
"Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)."
)
profile_dir = get_profile_dir(name)
profile_dir = get_profile_dir(canon)
if profile_dir.exists():
raise FileExistsError(f"Profile '{name}' already exists at {profile_dir}")
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
# Resolve clone source
source_dir = None
@ -440,6 +470,7 @@ def create_profile(
from hermes_constants import get_hermes_home
source_dir = get_hermes_home()
else:
clone_from = normalize_profile_name(clone_from)
validate_profile_name(clone_from)
source_dir = get_profile_dir(clone_from)
if not source_dir.is_dir():
@ -540,24 +571,25 @@ def delete_profile(name: str, yes: bool = False) -> Path:
Returns the path that was removed.
"""
validate_profile_name(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
if name == "default":
if canon == "default":
raise ValueError(
"Cannot delete the default profile (~/.hermes).\n"
"To remove everything, use: hermes uninstall"
)
profile_dir = get_profile_dir(name)
profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{name}' does not exist.")
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
# Show what will be deleted
model, provider = _read_config_model(profile_dir)
gw_running = _check_gateway_running(profile_dir)
skill_count = _count_skills(profile_dir)
print(f"\nProfile: {name}")
print(f"\nProfile: {canon}")
print(f"Path: {profile_dir}")
if model:
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
@ -569,7 +601,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
]
# Check for service
wrapper_path = _get_wrapper_dir() / name
wrapper_path = _get_wrapper_dir() / canon
has_wrapper = wrapper_path.exists()
if has_wrapper:
items.append(f"Command alias ({wrapper_path})")
@ -584,16 +616,16 @@ def delete_profile(name: str, yes: bool = False) -> Path:
if not yes:
print()
try:
confirm = input(f"Type '{name}' to confirm: ").strip()
confirm = input(f"Type '{canon}' to confirm: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nCancelled.")
return profile_dir
if confirm != name:
if confirm != canon:
print("Cancelled.")
return profile_dir
# 1. Disable service (prevents auto-restart)
_cleanup_gateway_service(name, profile_dir)
_cleanup_gateway_service(canon, profile_dir)
# 2. Stop running gateway
if gw_running:
@ -601,7 +633,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
# 3. Remove wrapper script
if has_wrapper:
if remove_wrapper_script(name):
if remove_wrapper_script(canon):
print(f"✓ Removed {wrapper_path}")
# 4. Remove profile directory
@ -614,13 +646,13 @@ def delete_profile(name: str, yes: bool = False) -> Path:
# 5. Clear active_profile if it pointed to this profile
try:
active = get_active_profile()
if active == name:
if active == canon:
set_active_profile("default")
print("✓ Active profile reset to default")
except Exception:
pass
print(f"\nProfile '{name}' deleted.")
print(f"\nProfile '{canon}' deleted.")
return profile_dir
@ -730,22 +762,23 @@ def set_active_profile(name: str) -> None:
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
"""
validate_profile_name(name)
if name != "default" and not profile_exists(name):
canon = normalize_profile_name(name)
validate_profile_name(canon)
if canon != "default" and not profile_exists(canon):
raise FileNotFoundError(
f"Profile '{name}' does not exist. "
f"Create it with: hermes profile create {name}"
f"Profile '{canon}' does not exist. "
f"Create it with: hermes profile create {canon}"
)
path = _get_active_profile_path()
path.parent.mkdir(parents=True, exist_ok=True)
if name == "default":
if canon == "default":
# Remove the file to indicate default
path.unlink(missing_ok=True)
else:
# Atomic write
tmp = path.with_suffix(".tmp")
tmp.write_text(name + "\n")
tmp.write_text(canon + "\n")
tmp.replace(path)
@ -811,16 +844,17 @@ def export_profile(name: str, output_path: str) -> Path:
"""
import tempfile
validate_profile_name(name)
profile_dir = get_profile_dir(name)
canon = normalize_profile_name(name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{name}' does not exist.")
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
output = Path(output_path)
# shutil.make_archive wants the base name without extension
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
if name == "default":
if canon == "default":
# The default profile IS ~/.hermes itself — its parent is ~/ and its
# directory name is ".hermes", not "default". We stage a clean copy
# under a temp dir so the archive contains ``default/...``.
@ -836,14 +870,14 @@ def export_profile(name: str, output_path: str) -> Path:
# Named profiles — stage a filtered copy to exclude credentials
with tempfile.TemporaryDirectory() as tmpdir:
staged = Path(tmpdir) / name
staged = Path(tmpdir) / canon
_CREDENTIAL_FILES = {"auth.json", ".env"}
shutil.copytree(
profile_dir,
staged,
ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
)
result = shutil.make_archive(base, "gztar", tmpdir, name)
result = shutil.make_archive(base, "gztar", tmpdir, canon)
return Path(result)
@ -952,16 +986,17 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
# Archives exported from the default profile have "default/" as top-level
# dir. Importing as "default" would target ~/.hermes itself — disallow
# that and guide the user toward a named profile.
if inferred_name == "default":
canon = normalize_profile_name(inferred_name)
validate_profile_name(canon)
if canon == "default":
raise ValueError(
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
"Specify a different name: hermes profile import <archive> --name <name>"
)
validate_profile_name(inferred_name)
profile_dir = get_profile_dir(inferred_name)
profile_dir = get_profile_dir(canon)
if profile_dir.exists():
raise FileExistsError(f"Profile '{inferred_name}' already exists at {profile_dir}")
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
profiles_root = _get_profiles_root()
profiles_root.mkdir(parents=True, exist_ok=True)
@ -977,8 +1012,8 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
)
final_source = extracted
if archive_root != inferred_name:
final_source = staging_root / inferred_name
if archive_root != canon:
final_source = staging_root / canon
extracted.rename(final_source)
shutil.move(str(final_source), str(profile_dir))
@ -1048,25 +1083,27 @@ def rename_profile(old_name: str, new_name: str) -> Path:
Returns the new profile directory.
"""
validate_profile_name(old_name)
validate_profile_name(new_name)
old_canon = normalize_profile_name(old_name)
new_canon = normalize_profile_name(new_name)
validate_profile_name(old_canon)
validate_profile_name(new_canon)
if old_name == "default":
if old_canon == "default":
raise ValueError("Cannot rename the default profile.")
if new_name == "default":
if new_canon == "default":
raise ValueError("Cannot rename to 'default' — it is reserved.")
old_dir = get_profile_dir(old_name)
new_dir = get_profile_dir(new_name)
old_dir = get_profile_dir(old_canon)
new_dir = get_profile_dir(new_canon)
if not old_dir.is_dir():
raise FileNotFoundError(f"Profile '{old_name}' does not exist.")
raise FileNotFoundError(f"Profile '{old_canon}' does not exist.")
if new_dir.exists():
raise FileExistsError(f"Profile '{new_name}' already exists.")
raise FileExistsError(f"Profile '{new_canon}' already exists.")
# 1. Stop gateway if running
if _check_gateway_running(old_dir):
_cleanup_gateway_service(old_name, old_dir)
_cleanup_gateway_service(old_canon, old_dir)
_stop_gateway_process(old_dir)
# 2. Rename directory
@ -1074,22 +1111,22 @@ def rename_profile(old_name: str, new_name: str) -> Path:
print(f"✓ Renamed {old_dir.name}{new_dir.name}")
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
_migrate_honcho_profile_host(old_name, new_name, new_dir)
_migrate_honcho_profile_host(old_canon, new_canon, new_dir)
# 4. Update wrapper script
remove_wrapper_script(old_name)
collision = check_alias_collision(new_name)
remove_wrapper_script(old_canon)
collision = check_alias_collision(new_canon)
if not collision:
create_wrapper_script(new_name)
print(f"✓ Alias updated: {new_name}")
create_wrapper_script(new_canon)
print(f"✓ Alias updated: {new_canon}")
else:
print(f"⚠ Cannot create alias '{new_name}'{collision}")
print(f"⚠ Cannot create alias '{new_canon}'{collision}")
# 5. Update active_profile if it pointed to old name
try:
if get_active_profile() == old_name:
set_active_profile(new_name)
print(f"✓ Active profile updated: {new_name}")
if get_active_profile() == old_canon:
set_active_profile(new_canon)
print(f"✓ Active profile updated: {new_canon}")
except Exception:
pass
@ -1191,13 +1228,14 @@ def resolve_profile_env(profile_name: str) -> str:
Called early in the CLI entry point, before any hermes modules
are imported, to set the HERMES_HOME environment variable.
"""
validate_profile_name(profile_name)
profile_dir = get_profile_dir(profile_name)
canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon)
if profile_name != "default" and not profile_dir.is_dir():
if canon != "default" and not profile_dir.is_dir():
raise FileNotFoundError(
f"Profile '{profile_name}' does not exist. "
f"Create it with: hermes profile create {profile_name}"
f"Profile '{canon}' does not exist. "
f"Create it with: hermes profile create {canon}"
)
return str(profile_dir)

View file

@ -108,9 +108,14 @@ class PtyBridge:
"(or pip install -e '.[pty]')."
)
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
# Let caller-supplied env fully override inheritance; if they pass
# None we inherit the server's env (same semantics as subprocess).
spawn_env = os.environ.copy() if env is None else env
# PTY-hosted programs expect TERM to describe the terminal type.
# CI often runs without TERM in the parent process, which makes
# simple terminal probes like `tput cols` fail before winsize reads.
# Preserve explicit caller overrides, but backfill a sensible default
# when TERM is missing or blank.
spawn_env = (os.environ.copy() if env is None else env.copy())
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"
proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr]
list(argv),
cwd=cwd,

View file

@ -964,7 +964,8 @@ def setup_model_provider(config: dict, *, quick: bool = False):
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
@ -1328,15 +1329,13 @@ def setup_terminal_backend(config: dict):
print_success("Terminal backend: Local")
print_info("Commands run directly on this machine.")
# CWD for messaging
# Gateway/cron working directory
print()
print_info("Working directory for messaging sessions:")
print_info(" When using Hermes via Telegram/Discord, this is where")
print_info(
" the agent starts. CLI mode always starts in the current directory."
)
print_info("Gateway working directory:")
print_info(" Used by Telegram/Discord/cron sessions.")
print_info(" CLI/TUI always uses your launch directory instead.")
current_cwd = cfg_get(config, "terminal", "cwd", default="")
cwd = prompt(" Messaging working directory", current_cwd or str(Path.home()))
cwd = prompt(" Gateway working directory", current_cwd or str(Path.home()))
if cwd:
config["terminal"]["cwd"] = cwd
@ -2049,6 +2048,16 @@ def _setup_slack():
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
print()
print_info("📬 Home Channel: where Hermes delivers cron job results,")
print_info(" cross-platform messages, and notifications.")
print_info(" To get a channel ID: open the channel in Slack, then right-click")
print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).")
print_info(" You can also set this later by typing /set-home in a Slack channel.")
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
if home_channel:
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
def _write_slack_manifest_and_instruct():
"""Generate the Slack manifest, write it under HERMES_HOME, and print
@ -2995,6 +3004,21 @@ def run_setup_wizard(args):
config = load_config()
hermes_home = get_hermes_home()
# Back up existing config before setup modifies it (#3522)
config_path = get_config_path()
if config_path.exists():
from datetime import datetime as _dt
_backup_path = config_path.with_suffix(
f".yaml.bak.{_dt.now().strftime('%Y%m%d_%H%M%S')}"
)
try:
import shutil
shutil.copy2(config_path, _backup_path)
except Exception:
_backup_path = None
else:
_backup_path = None
# Detect non-interactive environments (headless SSH, Docker, CI/CD)
non_interactive = getattr(args, 'non_interactive', False)
if not non_interactive and not is_interactive_stdin():
@ -3164,6 +3188,10 @@ def run_setup_wizard(args):
# Save and show summary
save_config(config)
if _backup_path and _backup_path.exists():
print_info(f"Previous config backed up to: {_backup_path}")
print_info("If setup changed a value you customized, restore it with:")
print_info(f" cp {_backup_path} {config_path}")
_print_setup_summary(config, hermes_home)
_offer_launch_chat()

View file

@ -122,11 +122,16 @@ def show_status(args):
print()
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
keys = {
# Values may be a single env var name (str) or a tuple of alternates (first found wins).
keys: dict[str, str | tuple[str, ...]] = {
"OpenRouter": "OPENROUTER_API_KEY",
"OpenAI": "OPENAI_API_KEY",
"NVIDIA": "NVIDIA_API_KEY",
"Z.AI/GLM": "GLM_API_KEY",
"Anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"),
"Google / Gemini": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
"DeepSeek": "DEEPSEEK_API_KEY",
"xAI / Grok": "XAI_API_KEY",
"NVIDIA NIM": "NVIDIA_API_KEY",
"Z.AI / GLM": "GLM_API_KEY",
"Kimi": "KIMI_API_KEY",
"StepFun Step Plan": "STEPFUN_API_KEY",
"MiniMax": "MINIMAX_API_KEY",
@ -142,8 +147,23 @@ def show_status(args):
"GitHub": "GITHUB_TOKEN",
}
for name, env_var in keys.items():
value = get_env_value(env_var) or ""
def _resolve_env(env_ref) -> str:
"""Return first non-empty env var value from a str or tuple of names."""
if isinstance(env_ref, tuple):
for candidate in env_ref:
v = get_env_value(candidate) or ""
if v:
return v
return ""
return get_env_value(env_ref) or ""
for name, env_ref in keys.items():
# Anthropic already has a dedicated lookup below; keep that as the
# single source of truth (it also resolves OAuth tokens), skip here
# so we don't print two "Anthropic" rows.
if name == "Anthropic":
continue
value = _resolve_env(env_ref)
has_key = bool(value)
display = redact_key(value) if not show_all else value
print(f" {name:<12} {check_mark(has_key)} {display}")

View file

@ -56,6 +56,7 @@ CONFIGURABLE_TOOLSETS = [
("file", "📁 File Operations", "read, write, patch, search"),
("code_execution", "⚡ Code Execution", "execute_code"),
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
("image_gen", "🎨 Image Generation", "image_generate"),
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
@ -78,7 +79,7 @@ CONFIGURABLE_TOOLSETS = [
# Toolsets that are OFF by default for new installs.
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
# but the setup checklist won't pre-select them for first-time users.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin"}
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "rl", "spotify", "discord", "discord_admin", "video"}
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
# these platforms, and only resolve/save for these platforms. A toolset
@ -1919,21 +1920,27 @@ def _reconfigure_provider(provider: dict, config: dict):
return
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
_print_success(f" TTS provider set to: {provider['tts_provider']}")
if "browser_provider" in provider:
bp = provider["browser_provider"]
browser_cfg = config.setdefault("browser", {})
if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
browser_cfg["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp
browser_cfg["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}")
browser_cfg["use_gateway"] = bool(managed_feature)
# Set web search backend in config if applicable
if provider.get("web_backend"):
config.setdefault("web", {})["backend"] = provider["web_backend"]
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
_print_success(f" Web backend set to: {provider['web_backend']}")
if managed_feature and managed_feature not in ("web", "tts", "browser"):

View file

@ -510,10 +510,23 @@ except (ValueError, TypeError):
)
_GATEWAY_HEALTH_TIMEOUT = 3.0
# DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT.
# Cross-container / cross-host gateway liveness detection will be folded into a
# first-class dashboard config key so it's no longer Docker-adjacent lore buried
# in env vars. The env vars still work for now so existing Compose deployments
# don't break. Do not add new callers — wire new uses through the planned
# config surface.
def _probe_gateway_health() -> tuple[bool, dict | None]:
"""Probe the gateway via its HTTP health endpoint (cross-container).
.. deprecated::
Driven by the deprecated ``GATEWAY_HEALTH_URL`` /
``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside
a move to a first-class dashboard config key. See
:data:`_GATEWAY_HEALTH_URL` for context.
Uses ``/health/detailed`` first (returns full state), falling back to
the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.

View file

@ -511,6 +511,12 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``,
and union types (``"type": ["integer", "string"]``).
Also wraps bare scalar values in a single-element list when the schema
declares ``"type": "array"``. Open-weight models (DeepSeek, Qwen, GLM)
sometimes emit ``{"urls": "https://a.com"}`` when the tool expects
``{"urls": ["https://a.com"]}``; wrapping here avoids a confusing tool
failure on what is otherwise a well-formed call.
"""
if not args or not isinstance(args, dict):
return args
@ -523,13 +529,42 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
if not properties:
return args
for key, value in args.items():
if not isinstance(value, str):
continue
for key, value in list(args.items()):
prop_schema = properties.get(key)
if not prop_schema:
continue
expected = prop_schema.get("type")
# Wrap bare non-list values when the schema declares ``array``.
# Strings still go through _coerce_value first so JSON-encoded
# arrays (``'["a","b"]'``) get parsed and nullable ``"null"``
# becomes ``None`` rather than ``["null"]``.
# ``None`` itself is preserved — we don't know whether the model
# meant "omit" or "empty list", and tools with sensible defaults
# (e.g. read_file's normalize_read_pagination) already handle it.
if expected == "array" and value is not None and not isinstance(value, (list, tuple)):
if isinstance(value, str):
coerced = _coerce_value(value, expected, schema=prop_schema)
if coerced is not value:
# _coerce_value handled it (JSON-parsed list or
# nullable "null" → None).
args[key] = coerced
continue
args[key] = [value]
logger.info(
"coerce_tool_args: wrapped bare string in list for %s.%s",
tool_name, key,
)
continue
args[key] = [value]
logger.info(
"coerce_tool_args: wrapped bare %s in list for %s.%s",
type(value).__name__, tool_name, key,
)
continue
if not isinstance(value, str):
continue
if not expected and not _schema_allows_null(prop_schema):
continue
coerced = _coerce_value(value, expected, schema=prop_schema)

View file

@ -82,14 +82,14 @@ film and music video. Often pairs with a diagramming tool.
Designs the visual language: framing, color, motion, transitions. Reviews
generator output for visual consistency. Hands off per-scene `VISUAL_SPEC.md`.
- **Toolsets:** kanban, terminal, file
- **Toolsets:** kanban, terminal, file, video, vision
- **Skills:** `kanban-worker` plus the visual skill that matches the project
(e.g., `ascii-video` for ASCII work, `manim-video` for explainers,
`touchdesigner-mcp` for real-time visuals, etc.)
- **Outputs:** `scenes/scene-NN/VISUAL_SPEC.md`, review comments on renderer
tasks
- **Reviews via:** any media-analysis approach (Gemini multimodal, manual
inspection of clip thumbnails, ffprobe summaries)
- **Reviews via:** `video_analyze` (sends full clip to multimodal LLM for
native review), `vision_analyze` for spot-checking frames, ffprobe summaries
## Production roles
@ -247,10 +247,10 @@ specifically on what's off (pacing, sync, brand alignment, technical
quality). Distinct from the cinematographer (who reviews visuals during
production) and the editor (who reviews for assembly).
- **Toolsets:** kanban, terminal, file
- **Toolsets:** kanban, terminal, file, video, vision
- **Skills:** `kanban-worker`
- **External tools:** any media-analysis approach (Gemini multimodal,
ffprobe, manual frame extraction)
- **Review tools:** `video_analyze` (native clip review via multimodal LLM),
`vision_analyze` (frame/thumbnail review), ffprobe
- **Outputs:** `review-notes.md`, comments on tasks
### brand-cop

View file

@ -81,7 +81,16 @@ them directly.
| Remotion CLI (`npx remotion render`) | React-based motion graphics | renderer-motion-graphics |
| Manim CE (`manim`) | Math animation render (driven by `manim-video` skill's recipes) | renderer-manim |
| Blender (`blender -b`) | 3D rendering (alternative to `blender-mcp`) | renderer-3d |
| Gemini multimodal / Claude vision | AI review of clips | reviewer, cinematographer, editor |
## Built-in Hermes tools for media review
These are native Hermes tools — not invoked via terminal but through their own
toolsets. Enable them per-profile by adding the toolset to the profile config.
| Tool | Toolset | What it does | Profile that uses it |
|------|---------|--------------|----------------------|
| `video_analyze` | `video` (opt-in — `hermes tools enable video`) | Native video understanding — sends full clip to a multimodal LLM (Gemini via OpenRouter) for review without frame extraction. Supports mp4, webm, mov, avi, mkv. 50 MB cap. Model: `AUXILIARY_VIDEO_MODEL` env → `AUXILIARY_VISION_MODEL` fallback. | reviewer, cinematographer, editor |
| `vision_analyze` | `vision` (core — enabled by default) | Image/frame analysis — review stills, thumbnails, exported frames. Already available to all profiles without opt-in. | reviewer, cinematographer, concept-artist |
## Standard toolset configurations per role
@ -156,6 +165,8 @@ toolsets:
- kanban
- terminal
- file
- video # video_analyze — review full clips natively
- vision # vision_analyze — review stills / exported frames
skills:
always_load:
- kanban-worker
@ -246,6 +257,8 @@ toolsets:
- kanban
- terminal
- file
- video # video_analyze — editor reviews assembled cuts natively
- vision # vision_analyze — spot-check frames
skills:
always_load:
- kanban-worker
@ -259,14 +272,13 @@ For captioner add Whisper invocation patterns to the SOUL.md.
```yaml
toolsets:
- kanban
- terminal # for media inspection
- terminal # for media inspection (ffprobe, etc.)
- file
- video # video_analyze — review full clips natively
- vision # vision_analyze — review stills / exported frames
skills:
always_load:
- kanban-worker
env_required:
- OPENROUTER_API_KEY # if using Gemini multimodal review
# or ANTHROPIC_API_KEY if using Claude vision (already required globally)
```
## API key requirements
@ -278,7 +290,7 @@ key is present in `~/.hermes/.env` (or macOS Keychain) before firing the kanban.
|---------|---------|---------|
| ElevenLabs | `ELEVENLABS_API_KEY` | voice-talent |
| OpenAI | `OPENAI_API_KEY` | image-generator (DALL-E), voice-talent (TTS) |
| OpenRouter | `OPENROUTER_API_KEY` | reviewer, cinematographer, editor (Gemini multimodal review) |
| OpenRouter | `OPENROUTER_API_KEY` | reviewer, cinematographer, editor (`video_analyze` routes through `AUXILIARY_VIDEO_MODEL` → OpenRouter) |
| FAL | `FAL_KEY` | image-generator (FAL flux models) |
| Replicate | `REPLICATE_API_TOKEN` | image-generator (alternate provider) |
| Runway | `RUNWAY_API_KEY` | image-to-video-generator |

View file

@ -43,7 +43,7 @@ class NodeServer:
def __init__(
self,
host: str = "0.0.0.0",
host: str = "127.0.0.1",
port: int = 18789,
token_path: Optional[Path] = None,
display_name: str = "hermes-meet-node",
@ -76,6 +76,13 @@ class NodeServer:
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
encoding="utf-8",
)
# Restrict to owner-read-write only — the token grants full RPC
# access to the meet bot (start, transcribe, speak in meetings).
try:
tmp.chmod(0o600)
except (OSError, NotImplementedError):
# Best-effort on non-POSIX filesystems; mode is set on POSIX.
pass
tmp.replace(self.token_path)
self._token = tok
return tok

View file

@ -11,6 +11,8 @@ Achievement system for the Hermes Dashboard: collectible, tiered badges generate
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
>
> **Share cards (2026-05-04, vendored in hermes-agent v0.4.0):** Unlocked achievement cards now have a "Share" button that renders a 1200×630 PNG share card (client-side canvas, no backend, no network) with Download + Copy-to-clipboard actions. Fits X/Twitter, Discord, LinkedIn, Bluesky link-preview dimensions.
## What it does

View file

@ -66,6 +66,296 @@
});
}
const TIER_HEX = {
"Copper": "#b87333",
"Silver": "#c0c7d2",
"Gold": "#f2c94c",
"Diamond": "#67e8f9",
"Olympian": "#c084fc",
};
function tierHex(tier) {
return TIER_HEX[tier] || "#67e8f9";
}
// Render a LUCIDE icon path fragment into a standalone SVG string with an
// explicit stroke color so it can be rasterized onto a <canvas> via Image.
// The normal render path uses stroke="currentColor" which browsers honor in
// DOM but NOT when the SVG is drawn to a canvas from a data URL.
function iconSvgForCanvas(iconKey, strokeColor) {
const paths = LUCIDE[iconKey] || LUCIDE.secret;
return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" " +
"stroke=\"" + strokeColor + "\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
paths + "</svg>";
}
function loadSvgImage(svgString) {
return new Promise(function (resolve, reject) {
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function (e) { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function wrapText(ctx, text, maxWidth) {
const words = String(text || "").split(/\s+/).filter(Boolean);
const lines = [];
let current = "";
for (let i = 0; i < words.length; i++) {
const candidate = current ? current + " " + words[i] : words[i];
if (ctx.measureText(candidate).width <= maxWidth) {
current = candidate;
} else {
if (current) lines.push(current);
current = words[i];
}
}
if (current) lines.push(current);
return lines;
}
// Build a 1200x630 share card PNG for a single achievement. Returns a Blob.
// Pure client-side render via Canvas2D — no external deps, no network.
async function buildShareImage(achievement) {
const W = 1200;
const H = 630;
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const tier = achievement.tier || achievement.next_tier || "Copper";
const color = tierHex(tier);
// Background: dark charcoal with a tier-tinted radial highlight on the
// top-left, echoing the card visual language.
ctx.fillStyle = "#0b0d11";
ctx.fillRect(0, 0, W, H);
const bgGrad = ctx.createRadialGradient(260, 220, 60, 260, 220, 820);
bgGrad.addColorStop(0, color + "33");
bgGrad.addColorStop(0.55, color + "0a");
bgGrad.addColorStop(1, "#0b0d1100");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
// Outer border
ctx.strokeStyle = color + "66";
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
// Icon block — 380x380 on the left
try {
const svg = iconSvgForCanvas(achievement.icon || "secret", color);
const iconImg = await loadSvgImage(svg);
const ix = 90;
const iy = 125;
const isize = 380;
// Icon glow
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 40;
ctx.drawImage(iconImg, ix, iy, isize, isize);
ctx.restore();
} catch (_) {
// Icon render failure is non-fatal; card still useful without it.
}
// Right column text layout
const rx = 520;
const rMaxWidth = W - rx - 70;
// Category label (kicker)
ctx.fillStyle = "#8b95a8";
ctx.font = "600 22px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.textBaseline = "top";
ctx.fillText((achievement.category || "").toUpperCase(), rx, 112);
// Achievement name — wrap to 2 lines if needed
ctx.fillStyle = "#ffffff";
ctx.font = "780 68px system-ui, -apple-system, 'Segoe UI', sans-serif";
const nameLines = wrapText(ctx, achievement.name || "Achievement", rMaxWidth).slice(0, 2);
let cursorY = 150;
for (let i = 0; i < nameLines.length; i++) {
ctx.fillText(nameLines[i], rx, cursorY);
cursorY += 76;
}
// Tier badge pill
const badgeLabel = tier.toUpperCase() + " TIER";
ctx.font = "700 22px ui-monospace, 'SF Mono', Menlo, monospace";
const badgeWidth = ctx.measureText(badgeLabel).width + 32;
const badgeX = rx;
const badgeY = cursorY + 14;
const badgeH = 40;
ctx.fillStyle = color + "1f";
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(badgeX, badgeY, badgeWidth, badgeH);
ctx.fill();
ctx.stroke();
ctx.fillStyle = color;
ctx.textBaseline = "middle";
ctx.fillText(badgeLabel, badgeX + 16, badgeY + badgeH / 2 + 1);
ctx.textBaseline = "top";
// Description — wrap up to 3 lines
ctx.fillStyle = "#c3cad6";
ctx.font = "400 26px system-ui, -apple-system, 'Segoe UI', sans-serif";
const descLines = wrapText(ctx, achievement.description || "", rMaxWidth).slice(0, 3);
let descY = badgeY + badgeH + 28;
for (let i = 0; i < descLines.length; i++) {
ctx.fillText(descLines[i], rx, descY);
descY += 34;
}
// Progress / stat line (if meaningful)
const progressValue = achievement.progress;
const threshold = achievement.next_threshold;
let statLine = null;
if (progressValue && threshold) {
statLine = progressValue.toLocaleString() + " / " + threshold.toLocaleString();
} else if (progressValue) {
statLine = progressValue.toLocaleString();
}
if (statLine) {
ctx.fillStyle = color;
ctx.font = "700 28px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.fillText(statLine, rx, descY + 14);
}
// Footer watermark
ctx.fillStyle = "#8b95a8";
ctx.font = "600 20px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.textBaseline = "bottom";
ctx.fillText("HERMES AGENT · hermes-agent.nousresearch.com", 70, H - 40);
// "UNLOCKED" stamp upper-right
ctx.textBaseline = "top";
ctx.fillStyle = color;
ctx.font = "800 24px ui-monospace, 'SF Mono', Menlo, monospace";
const stamp = "◆ UNLOCKED";
const stampW = ctx.measureText(stamp).width;
ctx.fillText(stamp, W - 70 - stampW, 70);
return await new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (blob) resolve(blob); else reject(new Error("canvas.toBlob returned null"));
}, "image/png");
});
}
function ShareDialog({ achievement, onClose }) {
const [status, setStatus] = hooks.useState("rendering"); // rendering | ready | copied | error
const [errorMsg, setErrorMsg] = hooks.useState(null);
const [previewUrl, setPreviewUrl] = hooks.useState(null);
const blobRef = React.useRef(null);
hooks.useEffect(function () {
let cancelled = false;
let createdUrl = null;
buildShareImage(achievement).then(function (blob) {
if (cancelled) return;
blobRef.current = blob;
createdUrl = URL.createObjectURL(blob);
setPreviewUrl(createdUrl);
setStatus("ready");
}).catch(function (err) {
if (cancelled) return;
setErrorMsg(String(err && err.message || err));
setStatus("error");
});
return function () {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [achievement.id]);
function download() {
if (!blobRef.current) return;
const url = URL.createObjectURL(blobRef.current);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-achievement-" + (achievement.id || "badge") + ".png";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
async function copyToClipboard() {
if (!blobRef.current) return;
try {
if (!navigator.clipboard || !window.ClipboardItem) {
throw new Error("Clipboard image copy not supported in this browser — use Download instead.");
}
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blobRef.current }),
]);
setStatus("copied");
setTimeout(function () { setStatus("ready"); }, 1800);
} catch (err) {
setErrorMsg(String(err && err.message || err));
setStatus("error");
}
}
// Build the pre-filled tweet text. Keep it short so X doesn't truncate
// when the user hasn't attached the PNG yet — they'll copy-image and
// paste in the same flow.
function tweetText() {
const tierPart = achievement.tier ? (achievement.tier + " tier ") : "";
return "Just unlocked " + tierPart + "\"" + achievement.name + "\" in Hermes Agent ☤\n\n" +
"@NousResearch · https://hermes-agent.nousresearch.com";
}
function shareOnX() {
const url = "https://x.com/intent/post?text=" + encodeURIComponent(tweetText());
window.open(url, "_blank", "noopener,noreferrer");
}
return React.createElement("div", {
className: "ha-share-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) onClose(); },
},
React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": "Share achievement" },
React.createElement("div", { className: "ha-share-head" },
React.createElement("strong", null, "Share: " + achievement.name),
React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": "Close" }, "×")
),
React.createElement("div", { className: "ha-share-preview" },
status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, "Rendering…"),
previewUrl && React.createElement("img", { src: previewUrl, alt: achievement.name + " share card" })
),
status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || "Something went wrong."),
React.createElement("div", { className: "ha-share-actions" },
React.createElement("button", {
className: "ha-share-btn ha-share-btn-primary",
onClick: shareOnX,
title: "Opens X with a pre-filled post",
}, "Share on X"),
React.createElement("button", {
className: "ha-share-btn",
onClick: copyToClipboard,
disabled: status !== "ready" && status !== "copied",
title: "Copy the image to paste into your post",
}, status === "copied" ? "Copied ✓" : "Copy image"),
React.createElement("button", {
className: "ha-share-btn",
onClick: download,
disabled: status !== "ready" && status !== "copied",
}, "Download PNG")
),
React.createElement("p", { className: "ha-share-hint" },
"Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere."
)
)
);
}
function StatCard(props) {
return React.createElement(C.Card, { className: "ha-stat" },
React.createElement(C.CardContent, { className: "ha-stat-content" },
@ -170,6 +460,7 @@
const targetTier = achievement.next_tier || achievement.tier;
const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective")));
const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : ""));
const [shareOpen, setShareOpen] = hooks.useState(false);
return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) },
React.createElement(C.CardContent, { className: "ha-card-content" },
React.createElement("div", { className: "ha-card-head" },
@ -180,7 +471,13 @@
),
React.createElement("div", { className: "ha-badges" },
React.createElement("span", { className: "ha-state-badge" }, stateLabel),
React.createElement("span", { className: "ha-tier-badge" }, tierLabel)
React.createElement("span", { className: "ha-tier-badge" }, tierLabel),
state === "unlocked" && React.createElement("button", {
className: "ha-share-trigger",
onClick: function () { setShareOpen(true); },
title: "Share this achievement",
"aria-label": "Share " + achievement.name,
}, "Share")
)
),
React.createElement("p", { className: "ha-description" }, achievement.description),
@ -200,7 +497,11 @@
),
React.createElement("span", { className: "ha-progress-text" }, progressText)
)
)
),
shareOpen && React.createElement(ShareDialog, {
achievement: achievement,
onClose: function () { setShareOpen(false); },
})
);
}

View file

@ -118,3 +118,29 @@
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
/* Share achievement trigger button on unlocked cards + modal dialog.
* Added to the vendored bundle (on top of the upstream PCinkusz base).
* Canvas rendering is pure client-side, no backend, no network.
*/
.ha-share-trigger { border: 1px solid color-mix(in srgb, var(--ha-tier) 58%, var(--color-border)); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .18rem .42rem; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); cursor: pointer; margin-top: .05rem; transition: background .12s ease, border-color .12s ease; }
.ha-share-trigger:hover { background: color-mix(in srgb, var(--ha-tier) 20%, transparent); border-color: var(--ha-tier); }
.ha-share-trigger:focus-visible { outline: 2px solid var(--ha-tier); outline-offset: 2px; }
.ha-share-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgba(4,6,10,.72); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; padding: 1.5rem; animation: ha-fade-in .14s ease-out; }
.ha-share-dialog { width: min(760px, 100%); max-height: calc(100vh - 3rem); overflow: auto; border: 1px solid color-mix(in srgb, var(--color-border) 70%, var(--color-ring)); background: color-mix(in srgb, var(--color-card) 94%, #000); box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: .9rem; padding: 1rem 1.1rem 1.1rem; }
.ha-share-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.ha-share-head strong { font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
.ha-share-close { width: 1.9rem; height: 1.9rem; display: grid; place-items: center; border: 1px solid var(--color-border); background: transparent; color: var(--color-muted-foreground); font-size: 1.1rem; cursor: pointer; line-height: 1; }
.ha-share-close:hover { color: var(--color-foreground); border-color: var(--color-ring); }
.ha-share-preview { position: relative; border: 1px solid var(--color-border); background: #0b0d11; overflow: hidden; aspect-ratio: 1200 / 630; }
.ha-share-preview img { display: block; width: 100%; height: 100%; object-fit: contain; }
.ha-share-placeholder { position: absolute; inset: 0; display: grid; place-items: center; color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; animation: ha-pulse 1.4s ease-in-out infinite; border-radius: 0; }
.ha-share-error { border: 1px solid #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 10%, transparent); padding: .55rem .7rem; font-size: .78rem; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-share-actions { display: flex; gap: .55rem; flex-wrap: wrap; }
.ha-share-btn { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-foreground); padding: .5rem .85rem; font-size: .82rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: border-color .12s ease, background .12s ease; }
.ha-share-btn:hover:not(:disabled) { border-color: var(--color-ring); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
.ha-share-btn:disabled { opacity: .5; cursor: not-allowed; }
.ha-share-btn-primary { border-color: #ffffff; color: #ffffff; background: #000000; }
.ha-share-btn-primary:hover:not(:disabled) { background: #1a1a1a; border-color: #67e8f9; color: #67e8f9; }
.ha-share-hint { margin: 0; color: var(--color-muted-foreground); font-size: .76rem; line-height: 1.45; }

View file

@ -3,7 +3,7 @@
"label": "Achievements",
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
"icon": "Star",
"version": "0.3.1",
"version": "0.4.0",
"tab": { "path": "/achievements", "position": "after:analytics" },
"entry": "dist/index.js",
"css": "dist/style.css",

View file

@ -203,11 +203,12 @@ class XAIImageGenProvider(ImageGenProvider):
)
response.raise_for_status()
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response else 0
response = exc.response
status = response.status_code if response is not None else 0
try:
err_msg = exc.response.json().get("error", {}).get("message", exc.response.text[:300])
err_msg = response.json().get("error", {}).get("message", response.text[:300])
except Exception:
err_msg = exc.response.text[:300] if exc.response else str(exc)
err_msg = response.text[:300] if response is not None else str(exc)
logger.error("xAI image gen failed (%d): %s", status, err_msg)
return error_response(
error=f"xAI image generation failed ({status}): {err_msg}",

View file

@ -63,6 +63,53 @@
const API = "/api/plugins/kanban";
const MIME_TASK = "text/x-hermes-task";
// localStorage key for the user's selected board. Independent of the
// CLI's on-disk ``<root>/kanban/current`` pointer so browser users
// can inspect any board without shifting the CLI's active board out
// from under a terminal they left open.
const LS_BOARD_KEY = "hermes.kanban.selectedBoard";
function readSelectedBoard() {
try {
const v = window.localStorage.getItem(LS_BOARD_KEY);
return (v || "").trim() || null;
} catch (_e) { return null; }
}
function writeSelectedBoard(slug) {
try {
if (slug && slug !== "default") window.localStorage.setItem(LS_BOARD_KEY, slug);
else window.localStorage.removeItem(LS_BOARD_KEY);
} catch (_e) { /* ignore quota / private mode */ }
}
function withBoard(url, board) {
// Append ?board=<slug> when a non-default board is active. Omitted
// for default so the URL stays clean and the backend falls through
// to its own resolution chain (env var → ``current`` file →
// default) which is already correct.
if (!board || board === "default") return url;
const sep = url.indexOf("?") >= 0 ? "&" : "?";
return `${url}${sep}board=${encodeURIComponent(board)}`;
}
// The SDK's Select component fires ``onValueChange(value)`` directly
// (it's a shadcn-style popup, not a native <select>). Older plugin
// code calls ``onChange({target: {value}})`` which silently never
// fires. This helper wires both signatures so a setter works with
// either API — use it as:
//
// h(Select, {..., ...selectChangeHandler(setState), ...})
function selectChangeHandler(setter) {
return {
onValueChange: function (v) { setter(v == null ? "" : v); },
onChange: function (e) {
const v = e && e.target ? e.target.value : e;
setter(v == null ? "" : v);
},
};
}
// -------------------------------------------------------------------------
// Minimal safe markdown renderer.
//
@ -245,7 +292,19 @@
// -------------------------------------------------------------------------
function KanbanPage() {
const [board, setBoard] = useState(null);
const [board, setBoard] = useState(() => readSelectedBoard() || "default");
const [boardList, setBoardList] = useState([]); // [{slug, name, counts, ...}]
const [showNewBoard, setShowNewBoard] = useState(false);
const [kanbanBoard, setKanbanBoard] = useState(null); // the grid data
// Alias so the rest of the function can keep using `board` semantically
// for the grid data (card columns + tenants + assignees) without
// colliding with the selected-board slug above. History: the old
// component had `const [board, setBoard]` for the grid data. We
// renamed the grid data to `kanbanBoard` so the more useful name
// (`board`) belongs to the selected slug.
const boardData = kanbanBoard;
const setBoardData = setKanbanBoard;
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@ -292,9 +351,9 @@
if (tenantFilter) qs.set("tenant", tenantFilter);
if (includeArchived) qs.set("include_archived", "true");
const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`;
return SDK.fetchJSON(url)
return SDK.fetchJSON(withBoard(url, board))
.then(function (data) {
setBoard(data);
setBoardData(data);
cursorRef.current = data.latest_event_id || 0;
setError(null);
})
@ -302,7 +361,26 @@
setError(String(err && err.message ? err.message : err));
})
.finally(function () { setLoading(false); });
}, [tenantFilter, includeArchived]);
}, [tenantFilter, includeArchived, board]);
// --- load list of boards for the switcher ------------------------------
const loadBoardList = useCallback(function () {
return SDK.fetchJSON(`${API}/boards`)
.then(function (data) {
const boards = (data && data.boards) || [];
setBoardList(boards);
// If the stored slug isn't in the list any longer (board was
// deleted in the CLI while dashboard was open), fall back to
// default so the UI doesn't hang on a 404.
if (board !== "default" && !boards.find(function (b) { return b.slug === board; })) {
setBoard("default");
writeSelectedBoard("default");
}
})
.catch(function () { /* non-fatal */ });
}, [board]);
useEffect(function () { loadBoardList(); }, [loadBoardList]);
const scheduleReload = useCallback(function () {
if (reloadTimerRef.current) return;
@ -324,16 +402,21 @@
// --- WebSocket ---------------------------------------------------------
useEffect(function () {
if (!board) return undefined;
if (!boardData) return undefined;
wsClosedRef.current = false;
function openWs() {
if (wsClosedRef.current) return;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({
const qsParams = {
since: String(cursorRef.current || 0),
token: token,
});
};
// Pin the WS stream to the currently-selected board so events
// from other boards don't bleed in. Only set for non-default so
// single-board installs keep the cleaner URL.
if (board && board !== "default") qsParams.board = board;
const qs = new URLSearchParams(qsParams);
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
@ -372,11 +455,11 @@
wsClosedRef.current = true;
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
};
}, [!!board, scheduleReload]);
}, [!!boardData, board, scheduleReload]);
// --- filtering ----------------------------------------------------------
const filteredBoard = useMemo(function () {
if (!board) return null;
if (!boardData) return null;
const q = search.trim().toLowerCase();
const filterTask = function (t) {
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
@ -386,18 +469,18 @@
}
return true;
};
return Object.assign({}, board, {
columns: board.columns.map(function (col) {
return Object.assign({}, boardData, {
columns: boardData.columns.map(function (col) {
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
}),
});
}, [board, assigneeFilter, search]);
}, [boardData, assigneeFilter, search]);
// --- actions ------------------------------------------------------------
const moveTask = useCallback(function (taskId, newStatus) {
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
if (confirmMsg && !window.confirm(confirmMsg)) return;
setBoard(function (b) {
setBoardData(function (b) {
if (!b) return b;
let moved = null;
const columns = b.columns.map(function (col) {
@ -413,7 +496,7 @@
}
return Object.assign({}, b, { columns });
});
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
@ -421,10 +504,10 @@
setError(`Move failed: ${err.message || err}`);
loadBoard();
});
}, [loadBoard]);
}, [loadBoard, board]);
const createTask = useCallback(function (body) {
return SDK.fetchJSON(`${API}/tasks`, {
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -437,9 +520,10 @@
setError("Task created, but: " + res.warning);
}
loadBoard();
loadBoardList(); // refresh counts in the switcher
return res;
});
}, [loadBoard]);
}, [loadBoard, loadBoardList, board]);
const toggleSelected = useCallback(function (id, additive) {
setSelectedIds(function (prev) {
@ -455,7 +539,7 @@
if (selectedIds.size === 0) return;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
SDK.fetchJSON(`${API}/tasks/bulk`, {
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -470,14 +554,50 @@
loadBoard();
})
.catch(function (e) { setError(String(e.message || e)); });
}, [selectedIds, loadBoard, clearSelected]);
}, [selectedIds, loadBoard, clearSelected, board]);
// --- board switching ----------------------------------------------------
const switchBoard = useCallback(function (nextSlug) {
if (!nextSlug || nextSlug === board) return;
// Optimistic UI: clear the current grid + show loading, reset the
// event cursor so the WS reopens aligned to the new board's
// latest_event_id on the next loadBoard.
setBoardData(null);
cursorRef.current = 0;
setLoading(true);
setBoard(nextSlug);
writeSelectedBoard(nextSlug);
}, [board]);
const createNewBoard = useCallback(function (payload) {
return SDK.fetchJSON(`${API}/boards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then(function (res) {
loadBoardList();
const slug = res && res.board && res.board.slug;
if (slug && payload.switch) switchBoard(slug);
return res;
});
}, [loadBoardList, switchBoard]);
const deleteBoard = useCallback(function (slug) {
if (!slug || slug === "default") return Promise.resolve();
return SDK.fetchJSON(`${API}/boards/${encodeURIComponent(slug)}`, {
method: "DELETE",
}).then(function () {
loadBoardList();
if (board === slug) switchBoard("default");
});
}, [board, loadBoardList, switchBoard]);
// --- render -------------------------------------------------------------
if (loading && !board) {
if (loading && !boardData) {
return h("div", { className: "p-8 text-sm text-muted-foreground" },
"Loading Kanban board…");
}
if (error && !board) {
if (error && !boardData) {
return h(Card, null,
h(CardContent, { className: "p-6" },
h("div", { className: "text-sm text-destructive" },
@ -493,15 +613,28 @@
return h(ErrorBoundary, null,
h("div", { className: "hermes-kanban flex flex-col gap-4" },
h(BoardToolbar, {
h(BoardSwitcher, {
board: board,
boardList: boardList,
onSwitch: switchBoard,
onNewClick: function () { setShowNewBoard(true); },
onDeleteBoard: deleteBoard,
}),
showNewBoard ? h(NewBoardDialog, {
onCancel: function () { setShowNewBoard(false); },
onCreate: function (payload) {
return createNewBoard(payload).then(function () { setShowNewBoard(false); });
},
}) : null,
h(BoardToolbar, {
board: boardData,
tenantFilter, setTenantFilter,
assigneeFilter, setAssigneeFilter,
includeArchived, setIncludeArchived,
laneByProfile, setLaneByProfile,
search, setSearch,
onNudgeDispatch: function () {
SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" })
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
.then(loadBoard)
.catch(function (e) { setError(String(e.message || e)); });
},
@ -509,7 +642,7 @@
}),
selectedIds.size > 0 ? h(BulkActionBar, {
count: selectedIds.size,
assignees: (board && board.assignees) || [],
assignees: (boardData && boardData.assignees) || [],
onApply: applyBulk,
onClear: clearSelected,
}) : null,
@ -522,20 +655,215 @@
onMove: moveTask,
onOpen: setSelectedTaskId,
onCreate: createTask,
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
}),
selectedTaskId ? h(TaskDrawer, {
taskId: selectedTaskId,
boardSlug: board,
onClose: function () { setSelectedTaskId(null); },
onRefresh: loadBoard,
renderMarkdown: renderMd,
allTasks: board.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
allTasks: boardData.columns.reduce(function (acc, c) { return acc.concat(c.tasks); }, []),
eventTick: taskEventTick[selectedTaskId] || 0,
}) : null,
),
);
}
// -------------------------------------------------------------------------
// Board switcher (multi-project)
// -------------------------------------------------------------------------
function BoardSwitcher(props) {
const list = props.boardList || [];
const current = list.find(function (b) { return b.slug === props.board; });
const currentName = current && current.name ? current.name : props.board;
const currentTotal = current ? current.total : 0;
const hasMultipleBoards = list.length > 1;
// Hide entirely when only the default board exists AND it's empty —
// single-project users never see boards UI unless they ask for it.
// We show the [+ New board] affordance as soon as any board has a
// task (so the user can discover multi-project before they need it)
// OR when any non-default board exists.
const totalAcrossAllBoards = list.reduce(function (n, b) { return n + (b.total || 0); }, 0);
const shouldShow = hasMultipleBoards || totalAcrossAllBoards > 0;
if (!shouldShow) {
return h("div", {
className: "hermes-kanban-boardswitcher-compact",
title: "Boards let you separate unrelated streams of work",
},
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-7 text-xs",
}, "+ New board"),
);
}
return h("div", { className: "hermes-kanban-boardswitcher" },
h("div", { className: "hermes-kanban-boardswitcher-inner" },
h("div", { className: "flex flex-col gap-0.5" },
h("div", { className: "text-[11px] uppercase tracking-wider text-muted-foreground" },
"Board"),
h("div", { className: "flex items-center gap-2" },
h(Select, Object.assign({
value: props.board,
className: "h-8 min-w-[220px]",
"aria-label": "Switch kanban board",
}, selectChangeHandler(function (v) { if (v) props.onSwitch(v); })),
list.map(function (b) {
const label = b.total > 0
? `${b.name || b.slug} · ${b.total}`
: (b.name || b.slug);
return h(SelectOption, { key: b.slug, value: b.slug }, label);
}),
),
h("span", { className: "text-xs text-muted-foreground" },
`${currentTotal || 0} task${currentTotal === 1 ? "" : "s"}`),
),
),
h("div", { className: "flex-1" }),
h(Button, {
onClick: props.onNewClick,
size: "sm",
className: "h-8",
}, "+ New board"),
props.board !== "default"
? h(Button, {
onClick: function () {
const msg =
`Archive board '${currentName}'? ` +
`It will be moved to boards/_archived/ so you can recover it later. ` +
`Tasks on this board will no longer appear anywhere in the UI.`;
if (window.confirm(msg)) props.onDeleteBoard(props.board);
},
size: "sm",
className: "h-8",
title: "Archive this board",
}, "Archive")
: null,
),
);
}
function NewBoardDialog(props) {
const [slug, setSlug] = useState("");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [switchTo, setSwitchTo] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState(null);
// Auto-derive a name from the slug if the user hasn't typed one.
const autoName = useMemo(function () {
if (!slug) return "";
return slug.replace(/[-_]+/g, " ")
.split(" ")
.filter(Boolean)
.map(function (w) { return w[0].toUpperCase() + w.slice(1); })
.join(" ");
}, [slug]);
function onSubmit(ev) {
if (ev) ev.preventDefault();
if (!slug.trim()) { setErr("slug is required"); return; }
setSubmitting(true);
setErr(null);
props.onCreate({
slug: slug.trim(),
name: name.trim() || autoName || undefined,
description: description.trim() || undefined,
icon: icon.trim() || undefined,
switch: switchTo,
}).catch(function (e) {
setErr(String(e && e.message ? e.message : e));
setSubmitting(false);
});
}
return h("div", {
className: "hermes-kanban-dialog-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) props.onCancel(); },
},
h("form", {
className: "hermes-kanban-dialog",
onSubmit: onSubmit,
},
h("div", { className: "hermes-kanban-dialog-title" }, "New board"),
h("div", { className: "text-xs text-muted-foreground mb-2" },
"Boards let you separate unrelated streams of work — one per project, repo, or domain. Workers on one board never see another board's tasks."),
h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Slug ",
h("span", { className: "text-muted-foreground" },
"— lowercase, hyphens, e.g. atm10-server")),
h(Input, {
value: slug,
onChange: function (e) { setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9\-_]/g, "-")); },
placeholder: "atm10-server",
autoFocus: true,
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Display name ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: name,
onChange: function (e) { setName(e.target.value); },
placeholder: autoName || "Display name",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Description ",
h("span", { className: "text-muted-foreground" }, "(optional)")),
h(Input, {
value: description,
onChange: function (e) { setDescription(e.target.value); },
placeholder: "What goes on this board?",
className: "h-8",
}),
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs" }, "Icon ",
h("span", { className: "text-muted-foreground" }, "(single character or emoji)")),
h(Input, {
value: icon,
onChange: function (e) { setIcon(e.target.value.slice(0, 4)); },
placeholder: "📦",
className: "h-8 w-24",
}),
),
h("label", { className: "flex items-center gap-2 text-xs" },
h("input", {
type: "checkbox",
checked: switchTo,
onChange: function (e) { setSwitchTo(e.target.checked); },
}),
"Switch to this board after creating it",
),
),
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
h("div", { className: "hermes-kanban-dialog-actions" },
h(Button, {
type: "button",
onClick: props.onCancel,
size: "sm",
disabled: submitting,
}, "Cancel"),
h(Button, {
type: "submit",
size: "sm",
disabled: submitting || !slug.trim(),
}, submitting ? "Creating…" : "Create board"),
),
),
);
}
// -------------------------------------------------------------------------
// Toolbar
// -------------------------------------------------------------------------
@ -555,11 +883,10 @@
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
h(Select, {
h(Select, Object.assign({
value: props.tenantFilter,
onChange: function (e) { props.setTenantFilter(e.target.value); },
className: "h-8",
},
}, selectChangeHandler(props.setTenantFilter)),
h(SelectOption, { value: "" }, "All tenants"),
tenants.map(function (t) {
return h(SelectOption, { key: t, value: t }, t);
@ -568,11 +895,10 @@
),
h("div", { className: "flex flex-col gap-1" },
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
h(Select, {
h(Select, Object.assign({
value: props.assigneeFilter,
onChange: function (e) { props.setAssigneeFilter(e.target.value); },
className: "h-8",
},
}, selectChangeHandler(props.setAssigneeFilter)),
h(SelectOption, { value: "" }, "All profiles"),
assignees.map(function (a) {
return h(SelectOption, { key: a, value: a }, a);
@ -919,6 +1245,12 @@
const [priority, setPriority] = useState(0);
const [parent, setParent] = useState("");
const [skills, setSkills] = useState("");
// Workspace controls. `scratch` (default) ignores path; `worktree` optionally
// takes a path (dispatcher derives one from the assignee profile otherwise);
// `dir` requires a path. Backend enforces the rule — we only hide/show the
// input here to save vertical space in the common `scratch` case.
const [workspaceKind, setWorkspaceKind] = useState("scratch");
const [workspacePath, setWorkspacePath] = useState("");
const submit = function () {
const trimmed = title.trim();
@ -938,10 +1270,23 @@
.map(function (s) { return s.trim(); })
.filter(function (s) { return s.length > 0; });
if (skillList.length > 0) body.skills = skillList;
// Only send workspace_kind when it's non-default. Keeps the request
// shape small and interoperable with older dispatcher versions.
if (workspaceKind && workspaceKind !== "scratch") {
body.workspace_kind = workspaceKind;
}
const wpTrim = workspacePath.trim();
if (wpTrim) body.workspace_path = wpTrim;
props.onSubmit(body);
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
setWorkspaceKind("scratch"); setWorkspacePath("");
};
const showPathInput = workspaceKind !== "scratch";
const pathPlaceholder = workspaceKind === "dir"
? "workspace path (required, e.g. ~/projects/my-app)"
: "workspace path (optional, derived from assignee if blank)";
return h("div", { className: "hermes-kanban-inline-create" },
h(Input, {
value: title,
@ -978,6 +1323,24 @@
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
className: "h-7 text-xs",
}),
h("div", { className: "flex gap-2" },
h(Select, {
value: workspaceKind,
onChange: function (e) { setWorkspaceKind(e.target.value); },
title: "scratch: isolated temp dir (default). worktree: git worktree on the assignee profile. dir: exact path (required below).",
className: "h-7 text-xs w-28",
},
h(SelectOption, { value: "scratch" }, "scratch"),
h(SelectOption, { value: "worktree" }, "worktree"),
h(SelectOption, { value: "dir" }, "dir"),
),
showPathInput ? h(Input, {
value: workspacePath,
onChange: function (e) { setWorkspacePath(e.target.value); },
placeholder: pathPlaceholder,
className: "h-7 text-xs flex-1",
}) : null,
),
h(Select, {
value: parent,
onChange: function (e) { setParent(e.target.value); },
@ -1012,13 +1375,14 @@
const [err, setErr] = useState(null);
const [newComment, setNewComment] = useState("");
const [editing, setEditing] = useState(false);
const boardSlug = props.boardSlug;
const load = useCallback(function () {
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`)
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug))
.then(function (d) { setData(d); setErr(null); })
.catch(function (e) { setErr(String(e.message || e)); })
.finally(function () { setLoading(false); });
}, [props.taskId]);
}, [props.taskId, boardSlug]);
// Reload when the WS stream reports new events for this task id
// (completion, block, crash, etc. — anything that'd make the drawer
@ -1033,7 +1397,7 @@
const handleComment = function () {
const body = newComment.trim();
if (!body) return;
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, {
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
@ -1048,7 +1412,7 @@
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
}
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, {
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
@ -1056,7 +1420,7 @@
};
const addLink = function (parentId) {
return SDK.fetchJSON(`${API}/links`, {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
@ -1065,12 +1429,12 @@
};
const removeLink = function (parentId) {
const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId });
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
const addChild = function (childId) {
return SDK.fetchJSON(`${API}/links`, {
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
@ -1079,7 +1443,7 @@
};
const removeChild = function (childId) {
const qs = new URLSearchParams({ parent_id: props.taskId, child_id: childId });
return SDK.fetchJSON(`${API}/links?${qs}`, { method: "DELETE" })
return SDK.fetchJSON(withBoard(`${API}/links?${qs}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setErr(String(e.message || e)); });
};
@ -1104,6 +1468,7 @@
data, editing, setEditing,
renderMarkdown: props.renderMarkdown,
allTasks: props.allTasks,
boardSlug: boardSlug,
onPatch: doPatch,
onAddParent: addLink,
onRemoveParent: removeLink,
@ -1216,7 +1581,7 @@
);
}),
),
h(WorkerLogSection, { taskId: t.id }),
h(WorkerLogSection, { taskId: t.id, boardSlug: props.boardSlug }),
h(RunHistorySection, { runs: props.data.runs || [] }),
);
}
@ -1287,10 +1652,10 @@
const [state, setState] = useState({ loading: false, data: null, err: null });
const load = useCallback(function () {
setState({ loading: true, data: null, err: null });
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`)
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/log?tail=100000`, props.boardSlug))
.then(function (d) { setState({ loading: false, data: d, err: null }); })
.catch(function (e) { setState({ loading: false, data: null, err: String(e.message || e) }); });
}, [props.taskId]);
}, [props.taskId, props.boardSlug]);
// Auto-load when the section mounts; the user opened the drawer so the
// cost is one small HTTP round-trip.

View file

@ -268,7 +268,7 @@
}
.hermes-kanban-drawer {
width: min(480px, 92vw);
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
height: 100vh;
background: var(--color-card);
border-left: 1px solid var(--color-border);
@ -334,7 +334,7 @@
.hermes-kanban-meta-row {
display: flex;
gap: 0.5rem;
font-size: 0.72rem;
font-size: 0.8rem;
}
.hermes-kanban-meta-label {
width: 92px;
@ -367,14 +367,15 @@
.hermes-kanban-pre {
margin: 0;
padding: 0.45rem 0.55rem;
padding: 0.5rem 0.6rem;
white-space: pre-wrap;
word-break: break-word;
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-foreground);
}
@ -605,8 +606,8 @@
/* ---- Markdown rendering -------------------------------------------- */
.hermes-kanban-md {
font-size: 0.8rem;
line-height: 1.55;
font-size: 0.85rem;
line-height: 1.6;
color: var(--color-foreground);
}
.hermes-kanban-md p { margin: 0.25rem 0; }
@ -632,15 +633,22 @@
}
.hermes-kanban-md code {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.75rem;
font-size: 0.8rem;
padding: 0.05rem 0.3rem;
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
border-radius: 3px;
color: inherit;
}
/* Fenced code block. Set a visible background even when --color-foreground
* is empty (color-mix falls through to transparent in that case), and force
* color: inherit so the text tracks the drawer foreground rather than the
* UA default on <code> elements otherwise themes that don't set
* --color-foreground leave code text rendering near-black on dark themes
* (see issue #18576). */
.hermes-kanban-md-code {
margin: 0.35rem 0;
padding: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--color-foreground) 5%, transparent);
background: color-mix(in srgb, currentColor 6%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 0.25rem);
overflow-x: auto;
@ -648,8 +656,9 @@
.hermes-kanban-md-code code {
background: transparent;
padding: 0;
font-size: 0.75rem;
font-size: 0.8rem;
white-space: pre;
color: inherit;
}
.hermes-kanban-md strong { font-weight: 600; }
@ -684,11 +693,11 @@
/* ---- Worker log pane ------------------------------------------------ */
.hermes-kanban-log {
max-height: 340px;
max-height: 360px;
overflow: auto;
white-space: pre;
font-size: 0.7rem;
line-height: 1.45;
font-size: 0.78rem;
line-height: 1.5;
}
@ -739,7 +748,8 @@
color: var(--color-muted-foreground);
}
.hermes-kanban-run-summary {
font-size: 0.75rem;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.2rem 0 0;
color: var(--color-foreground);
}
@ -751,10 +761,65 @@
}
.hermes-kanban-run-meta {
display: block;
font-size: 0.65rem;
font-size: 0.72rem;
line-height: 1.5;
padding: 0.15rem 0 0;
color: var(--color-muted-foreground);
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, ui-monospace, monospace);
}
/* -------------------------------------------------------------------------
Multi-project: board switcher + create-board dialog
------------------------------------------------------------------------- */
.hermes-kanban-boardswitcher {
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
border-radius: 0.5rem;
padding: 0.6rem 0.85rem;
background: var(--color-card-subtle, rgba(255, 255, 255, 0.02));
}
.hermes-kanban-boardswitcher-inner {
display: flex;
align-items: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.hermes-kanban-boardswitcher-compact {
display: flex;
justify-content: flex-end;
padding: 0 0.25rem;
}
.hermes-kanban-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(8, 10, 16, 0.55);
backdrop-filter: blur(2px);
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
}
.hermes-kanban-dialog {
background: var(--color-card, #121421);
color: var(--color-foreground);
border: 1px solid var(--color-border, rgba(120, 120, 140, 0.25));
border-radius: 0.5rem;
padding: 1.1rem 1.2rem 1rem;
width: 28rem;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 3rem);
overflow: auto;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);
}
.hermes-kanban-dialog-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.hermes-kanban-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}

View file

@ -72,19 +72,45 @@ def _check_ws_token(provided: Optional[str]) -> bool:
return hmac.compare_digest(str(provided), str(expected))
def _conn():
def _resolve_board(board: Optional[str]) -> Optional[str]:
"""Validate and normalise a board slug from a query param.
Raises :class:`HTTPException` 400 on malformed slugs so the browser
sees a clean error instead of a 500. Returns the normalised slug,
or ``None`` when the caller omitted the param (which then falls
through to the active board inside ``kb.connect()``).
"""
if board is None or board == "":
return None
try:
normed = kanban_db._normalize_board_slug(board)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if normed and normed != kanban_db.DEFAULT_BOARD and not kanban_db.board_exists(normed):
raise HTTPException(
status_code=404,
detail=f"board {normed!r} does not exist",
)
return normed
def _conn(board: Optional[str] = None):
"""Open a kanban_db connection, creating the schema on first use.
Every handler that mutates the DB goes through this so the plugin
self-heals on a fresh install (no user-visible "no such table"
error if somebody hits POST /tasks before GET /board).
``init_db`` is idempotent.
``board`` is the query-param slug (already normalised by
:func:`_resolve_board`). When ``None`` the active board is used
via the resolution chain (env var ``current`` file ``default``).
"""
try:
kanban_db.init_db()
kanban_db.init_db(board=board)
except Exception as exc:
log.warning("kanban init_db failed: %s", exc)
return kanban_db.connect()
return kanban_db.connect(board=board)
# ---------------------------------------------------------------------------
@ -177,13 +203,19 @@ def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]:
def get_board(
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
include_archived: bool = Query(False),
board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
):
"""Return the full board grouped by status column.
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
install doesn't surface a "failed to load" error on the plugin tab.
``board`` selects which board to read from. Omitting it falls
through to the active board (``HERMES_KANBAN_BOARD`` env on-disk
``current`` pointer ``default``).
"""
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
tasks = kanban_db.list_tasks(
conn, tenant=tenant, include_archived=include_archived
@ -274,8 +306,9 @@ def get_board(
# ---------------------------------------------------------------------------
@router.get("/tasks/{task_id}")
def get_task(task_id: str):
conn = _conn()
def get_task(task_id: str, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
task = kanban_db.get_task(conn, task_id)
if task is None:
@ -311,8 +344,9 @@ class CreateTaskBody(BaseModel):
@router.post("/tasks")
def create_task(payload: CreateTaskBody):
conn = _conn()
def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
task_id = kanban_db.create_task(
conn,
@ -373,8 +407,9 @@ class UpdateTaskBody(BaseModel):
@router.patch("/tasks/{task_id}")
def update_task(task_id: str, payload: UpdateTaskBody):
conn = _conn()
def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
task = kanban_db.get_task(conn, task_id)
if task is None:
@ -414,7 +449,12 @@ def update_task(task_id: str, payload: UpdateTaskBody):
ok = _set_status_direct(conn, task_id, "ready")
elif s == "archived":
ok = kanban_db.archive_task(conn, task_id)
elif s in ("todo", "running", "triage"):
elif s == "running":
raise HTTPException(
status_code=400,
detail="Cannot set status to 'running' directly; use the dispatcher/claim path",
)
elif s in ("todo", "triage"):
ok = _set_status_direct(conn, task_id, s)
else:
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
@ -527,10 +567,11 @@ class CommentBody(BaseModel):
@router.post("/tasks/{task_id}/comments")
def add_comment(task_id: str, payload: CommentBody):
def add_comment(task_id: str, payload: CommentBody, board: Optional[str] = Query(None)):
if not payload.body.strip():
raise HTTPException(status_code=400, detail="body is required")
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
@ -552,8 +593,9 @@ class LinkBody(BaseModel):
@router.post("/links")
def add_link(payload: LinkBody):
conn = _conn()
def add_link(payload: LinkBody, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
return {"ok": True}
@ -564,8 +606,13 @@ def add_link(payload: LinkBody):
@router.delete("/links")
def delete_link(parent_id: str = Query(...), child_id: str = Query(...)):
conn = _conn()
def delete_link(
parent_id: str = Query(...),
child_id: str = Query(...),
board: Optional[str] = Query(None),
):
board = _resolve_board(board)
conn = _conn(board=board)
try:
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
return {"ok": bool(ok)}
@ -586,7 +633,7 @@ class BulkTaskBody(BaseModel):
@router.post("/tasks/bulk")
def bulk_update(payload: BulkTaskBody):
def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
"""Apply the same patch to every id in ``payload.ids``.
This is an *independent* iteration per-task failures don't abort
@ -596,7 +643,8 @@ def bulk_update(payload: BulkTaskBody):
if not ids:
raise HTTPException(status_code=400, detail="ids is required")
results: list[dict] = []
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
for tid in ids:
entry: dict[str, Any] = {"id": tid, "ok": True}
@ -690,14 +738,15 @@ def get_config():
# ---------------------------------------------------------------------------
@router.get("/stats")
def get_stats():
def get_stats(board: Optional[str] = Query(None)):
"""Per-status + per-assignee counts + oldest-ready age.
Designed for the dashboard HUD and for router profiles that need to
answer "is this specialist overloaded?" without scanning the whole
board themselves.
"""
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
return kanban_db.board_stats(conn)
finally:
@ -705,7 +754,7 @@ def get_stats():
@router.get("/assignees")
def get_assignees():
def get_assignees(board: Optional[str] = Query(None)):
"""Known profiles + per-profile task counts.
Returns the union of ``~/.hermes/profiles/*`` on disk and every
@ -713,7 +762,8 @@ def get_assignees():
this to populate its assignee dropdown so a freshly-created profile
appears in the picker before it's been given any task.
"""
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
return {"assignees": kanban_db.known_assignees(conn)}
finally:
@ -725,7 +775,11 @@ def get_assignees():
# ---------------------------------------------------------------------------
@router.get("/tasks/{task_id}/log")
def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_000)):
def get_task_log(
task_id: str,
tail: Optional[int] = Query(None, ge=1, le=2_000_000),
board: Optional[str] = Query(None),
):
"""Return the worker's stdout/stderr log.
``tail`` caps the response size (bytes) so the dashboard drawer
@ -734,15 +788,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
``_rotate_worker_log`` a single ``.log.1`` is kept, no further
generations, so disk usage per task is bounded at ~4 MiB.
"""
conn = _conn()
board = _resolve_board(board)
conn = _conn(board=board)
try:
task = kanban_db.get_task(conn, task_id)
finally:
conn.close()
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
content = kanban_db.read_worker_log(task_id, tail_bytes=tail)
log_path = kanban_db.worker_log_path(task_id)
content = kanban_db.read_worker_log(task_id, tail_bytes=tail, board=board)
log_path = kanban_db.worker_log_path(task_id, board=board)
size = log_path.stat().st_size if log_path.exists() else 0
return {
"task_id": task_id,
@ -760,11 +815,16 @@ def get_task_log(task_id: str, tail: Optional[int] = Query(None, ge=1, le=2_000_
# ---------------------------------------------------------------------------
@router.post("/dispatch")
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
conn = _conn()
def dispatch(
dry_run: bool = Query(False),
max_n: int = Query(8, alias="max"),
board: Optional[str] = Query(None),
):
board = _resolve_board(board)
conn = _conn(board=board)
try:
result = kanban_db.dispatch_once(
conn, dry_run=dry_run, max_spawn=max_n,
conn, dry_run=dry_run, max_spawn=max_n, board=board,
)
# DispatchResult is a dataclass.
try:
@ -775,6 +835,124 @@ def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
conn.close()
# ---------------------------------------------------------------------------
# Boards CRUD (multi-project support)
# ---------------------------------------------------------------------------
class CreateBoardBody(BaseModel):
slug: str
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
switch: bool = False
class RenameBoardBody(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
icon: Optional[str] = None
color: Optional[str] = None
def _board_counts(slug: str) -> dict[str, int]:
"""Return ``{status: count}`` for a board. Safe on an empty DB."""
try:
path = kanban_db.kanban_db_path(board=slug)
if not path.exists():
return {}
conn = kanban_db.connect(board=slug)
try:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
).fetchall()
return {r["status"]: int(r["n"]) for r in rows}
finally:
conn.close()
except Exception:
return {}
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
boards = kanban_db.list_boards(include_archived=include_archived)
current = kanban_db.get_current_board()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
b["total"] = sum(b["counts"].values())
return {"boards": boards, "current": current}
@router.post("/boards")
def create_board_endpoint(payload: CreateBoardBody):
"""Create a new board. Idempotent — ``slug`` collision returns existing."""
try:
meta = kanban_db.create_board(
payload.slug,
name=payload.name,
description=payload.description,
icon=payload.icon,
color=payload.color,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if payload.switch:
try:
kanban_db.set_current_board(meta["slug"])
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"board": meta, "current": kanban_db.get_current_board()}
@router.patch("/boards/{slug}")
def rename_board(slug: str, payload: RenameBoardBody):
"""Update a board's display metadata (slug is immutable — create a new one to rename the directory)."""
try:
normed = kanban_db._normalize_board_slug(slug)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not normed or not kanban_db.board_exists(normed):
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
meta = kanban_db.write_board_metadata(
normed,
name=payload.name,
description=payload.description,
icon=payload.icon,
color=payload.color,
)
return {"board": meta}
@router.delete("/boards/{slug}")
def delete_board(slug: str, delete: bool = Query(False, description="Hard-delete instead of archive")):
"""Archive (default) or hard-delete a board."""
try:
res = kanban_db.remove_board(slug, archive=not delete)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"result": res, "current": kanban_db.get_current_board()}
@router.post("/boards/{slug}/switch")
def switch_board(slug: str):
"""Persist ``slug`` as the active board for subsequent CLI / slash calls.
Dashboard users pick boards via a client-side ``localStorage`` this
endpoint is for ``/kanban boards switch`` parity so gateway slash
commands and the CLI share the same current-board pointer.
"""
try:
normed = kanban_db._normalize_board_slug(slug)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not normed or not kanban_db.board_exists(normed):
raise HTTPException(status_code=404, detail=f"board {slug!r} does not exist")
kanban_db.set_current_board(normed)
return {"current": normed}
# ---------------------------------------------------------------------------
# WebSocket: /events?since=<event_id>
# ---------------------------------------------------------------------------
@ -802,8 +980,18 @@ async def stream_events(ws: WebSocket):
except ValueError:
cursor = 0
# Board selection — pinned at the WS handshake; re-subscribe to
# switch boards. Changing boards mid-stream would require
# reconciling two cursors, so the UI just opens a new WS on
# board change.
ws_board_raw = ws.query_params.get("board")
try:
ws_board = kanban_db._normalize_board_slug(ws_board_raw) if ws_board_raw else None
except ValueError:
ws_board = None
def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
conn = kanban_db.connect()
conn = kanban_db.connect(board=ws_board)
try:
rows = conn.execute(
"SELECT id, task_id, run_id, kind, payload, created_at "

View file

@ -592,6 +592,8 @@ def interactive_setup() -> None:
from hermes_cli.config import (
get_env_value,
save_env_value,
)
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_info,

View file

@ -3611,7 +3611,7 @@ class AIAgent:
_parent_runtime = self._current_main_runtime()
review_agent = AIAgent(
model=self.model,
max_iterations=8,
max_iterations=16,
quiet_mode=True,
platform=self.platform,
provider=self.provider,
@ -3629,6 +3629,14 @@ class AIAgent:
review_agent._user_profile_enabled = self._user_profile_enabled
review_agent._memory_nudge_interval = 0
review_agent._skill_nudge_interval = 0
# Suppress all status/warning emits from the fork so the
# user only sees the final successful-action summary.
# Without this, mid-review "Iteration budget exhausted",
# rate-limit retries, compression warnings, and other
# lifecycle messages bubble up through _emit_status ->
# _vprint and leak past the stdout redirect (they go via
# _print_fn/status_callback, which bypass sys.stdout).
review_agent.suppress_status_output = True
review_agent.run_conversation(
user_message=prompt,
@ -5056,6 +5064,23 @@ class AIAgent:
return tc.get("call_id", "") or tc.get("id", "") or ""
return getattr(tc, "call_id", "") or getattr(tc, "id", "") or ""
@staticmethod
def _get_tool_call_name_static(tc) -> str:
"""Extract function name from a tool_call entry (dict or object).
Gemini's OpenAI-compatibility endpoint requires every `role: tool`
message to carry the matching function name. OpenAI/Anthropic/ollama
tolerate its absence, so the field is best-effort: callers fall back
to "" and the message still works elsewhere.
"""
if isinstance(tc, dict):
fn = tc.get("function")
if isinstance(fn, dict):
return fn.get("name", "") or ""
return ""
fn = getattr(tc, "function", None)
return getattr(fn, "name", "") or ""
_VALID_API_ROLES = frozenset({"system", "user", "assistant", "tool", "function", "developer"})
@staticmethod
@ -5118,6 +5143,7 @@ class AIAgent:
if cid in missing_results:
patched.append({
"role": "tool",
"name": AIAgent._get_tool_call_name_static(tc),
"content": "[Result unavailable — see context summary above]",
"tool_call_id": cid,
})
@ -5816,6 +5842,17 @@ class AIAgent:
return primary_client
with self._openai_client_lock():
request_kwargs = dict(self._client_kwargs)
# Per-request OpenAI-wire clients (used by both the non-streaming
# chat-completions path and the streaming chat-completions path
# in `_interruptible_api_call`) should not run the SDK's built-in
# retry loop: the agent's outer loop owns retries with credential
# rotation, provider fallback, and backoff that the SDK can't
# see. Leaving SDK retries on (default 2) compounds with our outer
# retries and lets a single hung provider request stretch to ~3x
# the per-call timeout before our stale detector reports it.
# Shared/primary clients and Anthropic / Bedrock paths are
# unaffected (they don't go through here).
request_kwargs["max_retries"] = 0
if (
base_url_host_matches(str(request_kwargs.get("base_url", "")), "api.githubcopilot.com")
and self._api_kwargs_have_image_parts(api_kwargs or {})
@ -8192,6 +8229,7 @@ class AIAgent:
"""True when using an anthropic-compatible endpoint that preserves dots in model names.
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
MiniMax keeps dots (e.g. MiniMax-M2.7).
Xiaomi MiMo keeps dots (e.g. mimo-v2.5, mimo-v2.5-pro).
OpenCode Go/Zen keeps dots for non-Claude models (e.g. minimax-m2.5-free).
ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).
AWS Bedrock uses dotted inference-profile IDs
@ -8205,6 +8243,7 @@ class AIAgent:
"alibaba", "minimax", "minimax-cn",
"opencode-go", "opencode-zen",
"zai", "bedrock",
"xiaomi",
}:
return True
base = (getattr(self, "base_url", "") or "").lower()
@ -8214,6 +8253,7 @@ class AIAgent:
or "minimax" in base
or "opencode.ai/zen/" in base
or "bigmodel.cn" in base
or "xiaomimimo.com" in base
# AWS Bedrock runtime endpoints — defense-in-depth when
# ``provider`` is unset but ``base_url`` still names Bedrock.
or "bedrock-runtime." in base
@ -9008,6 +9048,7 @@ class AIAgent:
insert_at,
{
"role": "tool",
"name": function_name if function_name != "?" else "",
"tool_call_id": tool_call_id,
"content": marker,
},
@ -9412,6 +9453,7 @@ class AIAgent:
for tc in tool_calls:
messages.append({
"role": "tool",
"name": tc.function.name,
"content": f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
"tool_call_id": tc.id,
})
@ -9753,6 +9795,7 @@ class AIAgent:
tool_msg = {
"role": "tool",
"name": name,
"content": function_result,
"tool_call_id": tc.id,
}
@ -9790,6 +9833,7 @@ class AIAgent:
skipped_name = skipped_tc.function.name
skip_msg = {
"role": "tool",
"name": skipped_name,
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
"tool_call_id": skipped_tc.id,
}
@ -10140,6 +10184,7 @@ class AIAgent:
tool_msg = {
"role": "tool",
"name": function_name,
"content": function_result,
"tool_call_id": tool_call.id
}
@ -10166,6 +10211,7 @@ class AIAgent:
skipped_name = skipped_tc.function.name
skip_msg = {
"role": "tool",
"name": skipped_name,
"content": f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
"tool_call_id": skipped_tc.id
}
@ -10300,7 +10346,10 @@ class AIAgent:
provider_preferences["order"] = self.providers_order
if self.provider_sort:
provider_preferences["sort"] = self.provider_sort
if provider_preferences:
if provider_preferences and (
(self.provider or "").strip().lower() == "openrouter"
or self._is_openrouter_url()
):
summary_extra_body["provider"] = provider_preferences
if summary_extra_body:
@ -10418,6 +10467,15 @@ class AIAgent:
from hermes_logging import set_session_context
set_session_context(self.session_id)
# Bind the skill write-origin ContextVar for this thread so tool
# handlers (e.g. skill_manage create) can tell whether they are
# running inside the background self-improvement review fork vs.
# a foreground user-directed turn. Set at the top of each call;
# the review fork runs on its own thread with a fresh context,
# so the foreground value here does not leak into it.
from tools.skill_provenance import set_current_write_origin
set_current_write_origin(getattr(self, "_memory_write_origin", "assistant_tool"))
# If the previous turn activated fallback, restore the primary
# runtime so this turn gets a fresh attempt with the preferred model.
# No-op when _fallback_activated is False (gateway, first turn, etc.).
@ -10623,11 +10681,11 @@ class AIAgent:
self.model,
f"{self.context_compressor.context_length:,}",
)
if not self.quiet_mode:
self._safe_print(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {self.context_compressor.threshold_tokens:,} threshold"
)
self._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {self.context_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# May need multiple passes for very large sessions with small
# context windows (each pass summarises the middle N turns).
for _pass in range(3):
@ -13076,6 +13134,7 @@ class AIAgent:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({
"role": "tool",
"name": tc.function.name,
"tool_call_id": tc.id,
"content": content,
})
@ -13167,6 +13226,7 @@ class AIAgent:
tool_result = "Skipped: other tool call in this response had invalid JSON."
messages.append({
"role": "tool",
"name": tc.function.name,
"tool_call_id": tc.id,
"content": tool_result,
})
@ -13415,9 +13475,22 @@ class AIAgent:
m.get("role") == "tool"
for m in messages[-5:] # check recent messages
)
# Detect Qwen3/Ollama-style in-content thinking blocks.
# Ollama puts <think> in the content field (not in
# reasoning_content), so _has_structured below would
# miss it. We check here so thinking-only responses
# after tool calls route to prefill instead of nudge.
_has_inline_thinking = bool(
re.search(
r'<think>|<thinking>|<reasoning>',
final_response or "",
re.IGNORECASE,
)
)
if (
_prior_was_tool
and not getattr(self, "_post_tool_empty_retried", False)
and not _has_inline_thinking # thinking model still working — let prefill handle
):
self._post_tool_empty_retried = True
# Clear stale narration so it doesn't resurface
@ -13457,10 +13530,13 @@ class AIAgent:
# continue — the model will see its own reasoning
# on the next turn and produce the text portion.
# Inspired by clawdbot's "incomplete-text" recovery.
# Also covers Qwen3/Ollama in-content <think> blocks
# (detected above as _has_inline_thinking).
_has_structured = bool(
getattr(assistant_message, "reasoning", None)
or getattr(assistant_message, "reasoning_content", None)
or getattr(assistant_message, "reasoning_details", None)
or _has_inline_thinking
)
if _has_structured and self._thinking_prefill_retries < 2:
self._thinking_prefill_retries += 1
@ -13667,6 +13743,7 @@ class AIAgent:
if tc["id"] not in answered_ids:
err_msg = {
"role": "tool",
"name": AIAgent._get_tool_call_name_static(tc),
"tool_call_id": tc["id"],
"content": f"Error executing tool: {error_msg}",
}

View file

@ -48,6 +48,7 @@ AUTHOR_MAP = {
"127238744+teknium1@users.noreply.github.com": "teknium1",
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
"aludwin+gh@gmail.com": "adamludwin",
"ngusev@astralinux.ru": "NikolayGusev-astra",
"2093036+exiao@users.noreply.github.com": "exiao",
"rylen.anil@gmail.com": "rylena",
"godnanijatin@gmail.com": "jatingodnani",
@ -67,6 +68,8 @@ AUTHOR_MAP = {
"nbot@liizfq.top": "liizfq",
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
"dejie.guo@gmail.com": "JayGwod",
"133716830+0xKingBack@users.noreply.github.com": "0xKingBack",
"daixin1204@gmail.com": "SimbaKingjoe",
"maxence@groine.fr": "MaxyMoos",
"61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
# OpenViking viking_read salvage (April 2026)
@ -95,6 +98,7 @@ AUTHOR_MAP = {
"252818347@qq.com": "hejuntt1014",
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
"beliefanx@gmail.com": "BeliefanX",
"changchun989@proton.me": "changchun989",
"jefferson@heimdallstrategy.com": "Mind-Dragon",
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
"steve.westerhouse@origami-analytics.com": "westers",
@ -339,6 +343,8 @@ AUTHOR_MAP = {
"haileymarshall005@gmail.com": "haileymarshall",
"greer.guthrie@gmail.com": "g-guthrie",
"kennyx102@gmail.com": "bobashopcashier",
"77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
"25355950+megastary@users.noreply.github.com": "megastary", # PR #18325
"shokatalishaikh95@gmail.com": "areu01or00",
"bryan@intertwinesys.com": "bryanyoung",
"christo.mitov@gmail.com": "christomitov",
@ -457,6 +463,7 @@ AUTHOR_MAP = {
"centripetal-star@users.noreply.github.com": "centripetal-star",
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"cine.dreamer.one@gmail.com": "LeonSGP43",
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
"niyant@spicefi.xyz": "spniyant",
"olafthiele@gmail.com": "olafthiele",
@ -510,6 +517,7 @@ AUTHOR_MAP = {
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
"hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed)
"exx@example.com": "exxmen", # PR #19555
"hypnosis.mda@gmail.com": "Hypn0sis",
"ywt000818@gmail.com": "OwenYWT",
"dhandhalyabhavik@gmail.com": "v1k22",
@ -621,6 +629,84 @@ AUTHOR_MAP = {
"2114364329@qq.com": "cuyua9",
"2557058999@qq.com": "Disaster-Terminator",
"cine.dreamer.one@gmail.com": "LeonSGP43",
"zyprothh@gmail.com": "Zyproth",
"amitgaur@gmail.com": "amitgaur",
"albuquerque.abner@gmail.com": "mrbob-git",
"kiala@users.noreply.github.com": "kiala9",
"alanxchen@gmail.com": "alanxchen85",
"clawbot@clawbots-Mac-mini.local": "John-tip",
"der@konsi.org": "konsisumer",
"cirwel@The-CIRWEL-Group.local": "CIRWEL",
"molvikar8@gmail.com": "molvikar",
"nftpoetrist@gmail.com": "nftpoetrist",
"dodofun@126.com": "colorcross",
"1615063567@qq.com": "zhao0112",
"ethanguo.2003@gmail.com": "EthanGuo-coder",
"dev0jsh@gmail.com": "tmdgusya",
"leavr@163.com": "leavrcn",
"17683456+wanazhar@users.noreply.github.com": "wanazhar",
"26782336+cixuuz@users.noreply.github.com": "cixuuz",
"aleksandr.pasevin@openzeppelin.com": "pasevin",
"ubuntu@localhost.localdomain": "holynn-q",
"holynn@placeholder.local": "holynn-q",
"agent@hermes.local": "jacdevos",
"sunsky.lau@gmail.com": "liuhao1024",
"qiuqfang98@qq.com": "keepcalmqqf",
"261867348+ai-ag2026@users.noreply.github.com": "ai-ag2026",
"yanzh.su@gmail.com": "YanzhongSu",
"wanderwang@users.noreply.github.com": "WanderWang",
"yueheime@gmail.com": "yuehei",
"emidomh@gmail.com": "Emidomenge",
"2642448440@qq.com": "BlackJulySnow",
"4317663+helix4u@users.noreply.github.com": "helix4u",
"floptopbot33@gmail.com": "flobo3",
"dpaluy@users.noreply.github.com": "dpaluy",
"psikonetik@gmail.com": "el-analista",
"chenb19870707@gmail.com": "ms-alan",
"hex-clawd@users.noreply.github.com": "hex-clawd",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"barteq@hacknotes.local": "barteqpl",
"pama0227@gmail.com": "pama0227",
"52785845+ee-blog@users.noreply.github.com": "ee-blog",
"simplenamebox@gmail.com": "simplenamebox-ops",
"balyan.sid@gmail.com": "alt-glitch",
"xdord@xdorddeMac-mini.local": "foreverxdord",
"k2767567815@gmail.com": "QifengKuang",
"88077783+jjjojoj@users.noreply.github.com": "jjjojoj",
"valda@underscore.jp": "valda",
"lling486@163.com": "M3RCUR2Y",
"buraysandro9@gmail.com": "ygd58",
"ideathinklab01-source@users.noreply.github.com": "ideathinklab01-source",
"27987889@qq.com": "zng8418",
"daniuxie88@proton.me": "DaniuXie",
"panchanler@gmail.com": "ChanlerDev",
"252620095+briandevans@users.noreply.github.com": "briandevans",
"141889580+h0tp-ftw@users.noreply.github.com": "h0tp-ftw",
"chinadbo@foxmail.com": "chinadbo",
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
"xyywtt@gmail.com": "xyiy001",
"charliekerfoot@gmail.com": "CharlieKerfoot",
"grey0202@users.noreply.github.com": "Grey0202",
"vominh1919@gmail.com": "vominh1919",
"giwavictor9@gmail.com": "giwaov",
"yoimexex@gmail.com": "Yoimex",
"76803960+atongrun@users.noreply.github.com": "atongrun",
"michaeldanko@icloud.com": "MichaelWDanko",
"xudavid429@gmail.com": "YX234",
"kathy@Kathy.local": "julysir",
"274902531@qq.com": "JanCong",
"225304168+e-shizz@users.noreply.github.com": "e-shizz",
"vincent_hh@users.noreply.github.com": "VinVC",
"1243352777@qq.com": "zons-zhaozhy",
"dejie.guo@gmail.com": "JayGwod",
"52840391+swithek@users.noreply.github.com": "swithek",
"raipratik0101@gmail.com": "PratikRai0101",
"code@sasha.id": "sasha-id",
"chen.yunbo@xydigit.com": "chenyunbo411",
"openclaw@local": "Asce66",
"59465365+0xsir0000@users.noreply.github.com": "0xsir0000",
"lisanhu2014@hotmail.com": "lisanhu",
"0668001438@zte.com.cn": "chenyunbo411",
"leozeli@qq.com": "leozeli",
"linlehao@cuhk.edu.cn": "LehaoLin",
"liutong@isacas.ac.cn": "I3eg1nner",
@ -679,6 +765,8 @@ AUTHOR_MAP = {
"ztzheng@163.com": "chengoak", # PR #17467
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
# Debug share upload-time redaction (May 2026)
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
}

View file

@ -1113,6 +1113,45 @@ class TestBuildAnthropicKwargs:
assert _forbids_sampling_params("claude-opus-4-6") is False
assert _forbids_sampling_params("claude-sonnet-4-5") is False
def test_supports_fast_mode_predicate(self):
"""Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded."""
from agent.anthropic_adapter import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
assert _supports_fast_mode("claude-sonnet-4-6") is False
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
fast_mode=True,
)
# extra_body either absent or doesn't carry "speed"
assert "speed" not in kwargs.get("extra_body", {})
# No fast-mode beta header should be added either
beta_header = (kwargs.get("extra_headers") or {}).get("anthropic-beta", "")
assert "fast-mode-2026-02-01" not in beta_header
def test_fast_mode_still_applied_on_opus_46(self):
"""Regression guard — fast mode must still work on Opus 4.6."""
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
fast_mode=True,
)
assert kwargs.get("extra_body", {}).get("speed") == "fast"
assert "fast-mode-2026-02-01" in kwargs["extra_headers"]["anthropic-beta"]
def test_reasoning_disabled(self):
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",

View file

@ -1893,3 +1893,53 @@ class TestOpenRouterExplicitApiKey:
assert call_kwargs["api_key"] == "env-fallback-key", (
f"Expected env fallback key to be used when explicit_api_key is None, got: {call_kwargs['api_key']}"
)
class TestAnthropicExplicitApiKey:
"""Test that explicit_api_key is correctly propagated to _try_anthropic().
Parity with the OpenRouter fix in #18768: resolve_provider_client() passes
explicit_api_key to _try_openrouter(), but the anthropic branch was not
updated _try_anthropic() always fell back to resolve_anthropic_token()
even when an explicit key was supplied (e.g. from a fallback_model entry).
"""
def test_try_anthropic_uses_explicit_api_key_over_env(self):
"""_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic("explicit-pool-key")
assert client is not None
assert mock_build.call_args.args[0] == "explicit-pool-key", (
f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}"
)
assert mock_build.call_args.args[0] != "env-fallback-key"
def test_try_anthropic_without_explicit_key_falls_back_to_resolve(self):
"""Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic()
assert client is not None
assert mock_build.call_args.args[0] == "env-fallback-key"
def test_resolve_provider_client_passes_explicit_api_key_to_anthropic(self):
"""resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key."""
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="env-key"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = resolve_provider_client(
provider="anthropic",
explicit_api_key="explicit-fallback-key",
)
assert client is not None
assert mock_build.call_args.args[0] == "explicit-fallback-key", (
"resolve_provider_client must forward explicit_api_key to _try_anthropic()"
)

View file

@ -1283,18 +1283,21 @@ class TestIsStaleConnectionError:
"""Classifier that decides whether an exception warrants client eviction."""
def test_detects_botocore_connection_closed_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import ConnectionClosedError
exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_endpoint_connection_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import EndpointConnectionError
exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
assert is_stale_connection_error(exc) is True
def test_detects_botocore_read_timeout(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import is_stale_connection_error
from botocore.exceptions import ReadTimeoutError
exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
@ -1355,6 +1358,7 @@ class TestCallConverseInvalidatesOnStaleError:
reconnects instead of reusing the dead socket."""
def test_converse_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse,
@ -1381,6 +1385,7 @@ class TestCallConverseInvalidatesOnStaleError:
)
def test_converse_stream_evicts_client_on_stale_error(self):
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse_stream,
@ -1406,6 +1411,7 @@ class TestCallConverseInvalidatesOnStaleError:
def test_converse_does_not_evict_on_non_stale_error(self):
"""Non-stale errors (e.g. ValidationException) leave the client cache alone."""
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
from agent.bedrock_adapter import (
_bedrock_runtime_client_cache,
call_converse,

View file

@ -1281,6 +1281,47 @@ class TestTokenBudgetTailProtection:
assert isinstance(cut, int)
assert 0 <= cut <= len(messages)
def test_generous_budget_protects_everything_floor_does_not_override(
self, budget_compressor
):
"""A budget that covers the whole transcript must prune nothing —
``protect_tail_count`` is a minimum floor, not a ceiling."""
c = budget_compressor
# 100 alternating assistant/tool messages. Each tool result has
# *unique* content so the dedup pass (Pass 1, which is independent
# of prune_boundary) is a no-op and we isolate the boundary logic.
messages = []
for i in range(50):
messages.append({
"role": "assistant", "content": None,
"tool_calls": [{
"id": f"c{i}",
"type": "function",
"function": {"name": "noop", "arguments": "{}"},
}],
})
messages.append({
"role": "tool",
"tool_call_id": f"c{i}",
"content": f"unique-tool-output-{i:03d}-" + ("x" * 250),
})
# Budget large enough to cover the whole transcript many times over,
# so the budget walk completes without hitting its break condition
# and the boundary lands at 0 ("protect everything").
_, pruned = c._prune_old_tool_results(
messages,
protect_tail_count=20,
protect_tail_tokens=10_000_000,
)
assert pruned == 0, (
"budget said protect everything, but the floor still pruned "
f"{pruned} messages — protect_tail_count is acting as a ceiling, "
"not a minimum floor"
)
class TestUpdateModelBudgets:
"""Regression: update_model() must recalculate token budgets."""

View file

@ -154,6 +154,7 @@ def test_unused_skill_transitions_to_stale(curator_env):
long_ago = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
data = u.load_usage()
data["old-skill"] = u._empty_record()
data["old-skill"]["created_by"] = "agent"
data["old-skill"]["last_used_at"] = long_ago
data["old-skill"]["created_at"] = long_ago
u.save_usage(data)
@ -172,6 +173,7 @@ def test_very_old_skill_gets_archived(curator_env):
super_old = (datetime.now(timezone.utc) - timedelta(days=120)).isoformat()
data = u.load_usage()
data["ancient"] = u._empty_record()
data["ancient"]["created_by"] = "agent"
data["ancient"]["last_used_at"] = super_old
data["ancient"]["created_at"] = super_old
u.save_usage(data)
@ -192,6 +194,7 @@ def test_pinned_skill_is_never_touched(curator_env):
super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
data = u.load_usage()
data["precious"] = u._empty_record()
data["precious"]["created_by"] = "agent"
data["precious"]["last_used_at"] = super_old
data["precious"]["created_at"] = super_old
data["precious"]["pinned"] = True
@ -214,6 +217,7 @@ def test_stale_skill_reactivates_on_recent_use(curator_env):
recent = datetime.now(timezone.utc).isoformat()
data = u.load_usage()
data["revived"] = u._empty_record()
data["revived"]["created_by"] = "agent"
data["revived"]["state"] = "stale"
data["revived"]["last_used_at"] = recent
data["revived"]["created_at"] = recent
@ -240,6 +244,27 @@ def test_new_skill_without_last_used_not_immediately_archived(curator_env):
assert (skills_dir / "fresh").exists()
def test_manual_skill_is_not_auto_archived(curator_env):
"""Manual skills can have usage records, but without the agent-created
marker they must stay out of curator transitions."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
skill_dir = _write_skill(skills_dir, "manual")
super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
data = u.load_usage()
data["manual"] = u._empty_record()
data["manual"]["last_used_at"] = super_old
data["manual"]["created_at"] = super_old
u.save_usage(data)
counts = c.apply_automatic_transitions()
assert counts["checked"] == 0
assert counts["archived"] == 0
assert skill_dir.exists()
def test_bundled_skill_not_touched_by_transitions(curator_env):
c = curator_env["curator"]
u = curator_env["usage"]
@ -267,8 +292,10 @@ def test_bundled_skill_not_touched_by_transitions(curator_env):
def test_run_review_records_state(curator_env):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
result = c.run_curator_review(synchronous=True)
assert "started_at" in result
@ -284,8 +311,10 @@ def test_dry_run_does_not_advance_state(curator_env, monkeypatch):
`hermes curator status`. Fixes #18373.
"""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
# Stub the LLM so the test doesn't need a provider.
monkeypatch.setattr(
@ -311,8 +340,10 @@ def test_dry_run_injects_report_only_banner(curator_env, monkeypatch):
skips automatic transitions but the LLM prompt is the only guard
against the model calling skill_manage directly."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
captured = {}
def _stub(prompt):
@ -331,8 +362,10 @@ def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):
archives skills deterministically, and a preview must not touch the
filesystem."""
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
called = {"n": 0}
def _explode(*_a, **_kw):
@ -351,8 +384,10 @@ def test_dry_run_skips_automatic_transitions(curator_env, monkeypatch):
def test_run_review_synchronous_invokes_llm_stub(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
calls = []
def _stub(prompt):
@ -409,8 +444,10 @@ def test_maybe_run_curator_enforces_idle_gate(curator_env, monkeypatch):
def test_maybe_run_curator_runs_when_eligible(curator_env, monkeypatch):
c = curator_env["curator"]
u = curator_env["usage"]
skills_dir = curator_env["home"] / "skills"
_write_skill(skills_dir, "a")
u.mark_agent_created("a")
# Seed last_run_at far in the past so the interval gate opens — the
# "no state" path intentionally defers the first run now (#18373).
long_ago = datetime.now(timezone.utc) - timedelta(hours=c.get_interval_hours() * 2)
@ -645,6 +682,86 @@ def test_review_model_honors_auxiliary_curator_slot(curator_env):
)
def test_review_runtime_passes_auxiliary_curator_credentials(curator_env):
"""Per-slot api_key/base_url must ride into resolve_runtime_provider (not main-only creds)."""
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "custom",
"model": "local-mini",
"api_key": "sk-curator-only",
"base_url": "http://localhost:11434/v1",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert binding.provider == "custom"
assert binding.model == "local-mini"
assert binding.explicit_api_key == "sk-curator-only"
assert binding.explicit_base_url == "http://localhost:11434/v1"
def test_review_runtime_strips_blank_aux_credentials(curator_env):
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "openrouter",
"model": "x/y",
"api_key": " ",
"base_url": "",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert binding.explicit_api_key is None
assert binding.explicit_base_url is None
def test_review_runtime_ignores_auxiliary_credentials_when_using_main(curator_env):
"""Falling through to main model must not pick up stray auxiliary.curator secrets."""
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"auxiliary": {
"curator": {
"provider": "auto",
"model": "",
"api_key": "must-not-leak",
"base_url": "http://curator-slot-ignored/",
},
},
}
binding = curator._resolve_review_runtime(cfg)
assert (binding.provider, binding.model) == ("openrouter", "openai/gpt-5.5")
assert binding.explicit_api_key is None
assert binding.explicit_base_url is None
def test_review_runtime_legacy_auxiliary_carry_credentials(curator_env, caplog):
curator = curator_env["curator"]
cfg = {
"model": {"provider": "openrouter", "default": "openai/gpt-5.5"},
"curator": {
"auxiliary": {
"provider": "custom",
"model": "m",
"api_key": "legacy-key",
"base_url": "http://legacy/v1",
},
},
}
import logging
with caplog.at_level(logging.INFO, logger="agent.curator"):
binding = curator._resolve_review_runtime(cfg)
assert binding.explicit_api_key == "legacy-key"
assert binding.explicit_base_url == "http://legacy/v1"
assert any("deprecated curator.auxiliary" in rec.message for rec in caplog.records)
def test_review_model_auxiliary_curator_partial_override_falls_back(curator_env):
"""Only one of slot provider/model set → fall back to the main pair.

View file

@ -220,6 +220,81 @@ def test_classify_handles_malformed_arguments_string(curator_env):
assert len(result["pruned"]) == 1
def test_classify_no_false_positive_short_name_in_file_path(curator_env):
"""Short skill name that is a substring of another filename = pruned, not consolidated."""
# e.g. "api" should NOT match "references/api-design.md"
result = curator_env._classify_removed_skills(
removed=["api"],
added=[],
after_names={"conventions"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "write_file",
"name": "conventions",
"file_path": "references/api-design.md",
"file_content": "# API Design\n...",
}),
},
],
)
assert result["consolidated"] == [], (
f"Short name 'api' should NOT match file_path 'references/api-design.md'"
)
assert len(result["pruned"]) == 1
assert result["pruned"][0]["name"] == "api"
def test_classify_no_false_positive_short_name_in_content(curator_env):
"""Short skill name embedded in longer word in content = pruned, not consolidated."""
# e.g. "test" should NOT match content "running latest tests"
result = curator_env._classify_removed_skills(
removed=["test"],
added=[],
after_names={"umbrella"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "patch",
"name": "umbrella",
"old_string": "old",
"new_string": "running latest tests with pytest",
}),
},
],
)
assert result["consolidated"] == [], (
f"Short name 'test' should NOT match 'latest' via word boundary"
)
assert len(result["pruned"]) == 1
def test_classify_still_matches_exact_word_in_content(curator_env):
"""Word-boundary match still works for exact word occurrences."""
# "api" SHOULD match content "use the api gateway"
result = curator_env._classify_removed_skills(
removed=["api"],
added=[],
after_names={"gateway"},
tool_calls=[
{
"name": "skill_manage",
"arguments": json.dumps({
"action": "edit",
"name": "gateway",
"content": "# Gateway\n\nUse the api gateway for all requests.\n",
}),
},
],
)
assert len(result["consolidated"]) == 1, (
f"'api' should match as a standalone word in content"
)
assert result["consolidated"][0]["into"] == "gateway"
def test_report_md_splits_consolidated_and_pruned_sections(curator_env):
"""End-to-end: REPORT.md shows both sections distinctly."""
curator = curator_env

View file

@ -410,6 +410,24 @@ class TestClassifyApiError:
result = classify_api_error(e, approx_tokens=1000, context_length=200000)
assert result.reason == FailoverReason.format_error
def test_400_generic_many_messages_below_large_context_pressure_is_format_error(self):
"""Large-context sessions should not overflow solely due to message count."""
e = MockAPIError(
"Error",
status_code=400,
body={"error": {"message": "Error"}},
)
result = classify_api_error(
e,
provider="openai-codex",
model="gpt-5.5",
approx_tokens=74320,
context_length=1_000_000,
num_messages=432,
)
assert result.reason == FailoverReason.format_error
assert result.should_compress is False
# ── Server disconnect + large session ──
def test_disconnect_large_session_context_overflow(self):
@ -425,6 +443,20 @@ class TestClassifyApiError:
result = classify_api_error(e, approx_tokens=5000, context_length=200000)
assert result.reason == FailoverReason.timeout
def test_disconnect_many_messages_below_large_context_pressure_is_timeout(self):
"""Large-context disconnects should not overflow solely due to message count."""
e = Exception("server disconnected without sending complete message")
result = classify_api_error(
e,
provider="openai-codex",
model="gpt-5.5",
approx_tokens=74320,
context_length=1_000_000,
num_messages=432,
)
assert result.reason == FailoverReason.timeout
assert result.should_compress is False
# ── Provider-specific: Anthropic thinking signature ──
def test_anthropic_thinking_signature(self):

View file

@ -13,16 +13,13 @@ def test_vision_call_uses_resolved_provider_args():
usage=MagicMock(prompt_tokens=10, completion_tokens=5),
)
with (
patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
),
patch(
"agent.auxiliary_client.resolve_vision_provider_client",
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
) as mock_vision,
):
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
), patch(
"agent.auxiliary_client.resolve_vision_provider_client",
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
) as mock_vision:
call_llm(
"vision",
provider="raw-provider",
@ -38,3 +35,30 @@ def test_vision_call_uses_resolved_provider_args():
assert call_args.kwargs["model"] == "my-resolved-model"
assert call_args.kwargs["base_url"] == "http://resolved"
assert call_args.kwargs["api_key"] == "resolved-key"
def test_vision_base_url_override_keeps_explicit_provider():
"""Explicit provider should still drive credential resolution with custom base_url."""
from agent.auxiliary_client import resolve_vision_provider_client
fake_client = MagicMock()
with patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=(
"zai",
"glm-4v",
"https://open.bigmodel.cn/api/paas/v4",
None,
"chat_completions",
),
), patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(fake_client, "glm-4v"),
) as mock_resolve:
provider, client, model = resolve_vision_provider_client()
assert provider == "zai"
assert client is fake_client
assert model == "glm-4v"
assert mock_resolve.call_args.args[0] == "zai"
assert mock_resolve.call_args.kwargs["explicit_base_url"] == "https://open.bigmodel.cn/api/paas/v4"

View file

@ -126,6 +126,20 @@ class TestCodexBuildKwargs:
)
assert kw.get("extra_headers", {}).get("x-grok-conv-id") == "conv-123"
def test_xai_headers_preserve_request_override_headers(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(
model="grok-3", messages=messages, tools=[],
session_id="conv-123",
is_xai_responses=True,
request_overrides={"extra_headers": {"X-Test": "1", "X-Trace": "abc"}},
)
assert kw.get("extra_headers") == {
"X-Test": "1",
"X-Trace": "abc",
"x-grok-conv-id": "conv-123",
}
def test_minimal_effort_clamped(self, transport):
messages = [{"role": "user", "content": "Hi"}]
kw = transport.build_kwargs(

View file

@ -123,6 +123,13 @@ class TestBusyInputMode:
cli.process_command("/queue follow up")
assert cli._pending_input.get_nowait() == "follow up"
def test_q_alias_queues_prompt(self):
"""The /q alias should resolve to /queue, not /quit."""
cli = _make_cli()
cli._agent_running = False
assert cli.process_command("/q follow up") is True
assert cli._pending_input.get_nowait() == "follow up"
def test_queue_mode_routes_busy_enter_to_pending(self):
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "queue"}})

View file

@ -22,6 +22,23 @@ def test_final_assistant_content_uses_markdown_renderable():
assert "two" in output
def test_final_assistant_content_preserves_windows_hidden_dir_paths():
renderable = _render_final_assistant_content(
r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\"
)
output = _render_to_text(renderable)
assert r"D:\Projects\SourceCode\hermes-agent\.ai\skills" + "\\" in output
def test_final_assistant_content_keeps_non_path_markdown_escapes():
renderable = _render_final_assistant_content(r"1\. Not an ordered list")
output = _render_to_text(renderable)
assert "1. Not an ordered list" in output
assert r"1\." not in output
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import importlib
import os
import sys
from datetime import timedelta
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from hermes_state import SessionDB
@ -219,3 +219,59 @@ def test_new_session_resets_token_counters(tmp_path):
assert comp.last_total_tokens == 0
assert comp.compression_count == 0
assert comp._context_probed is False
def test_new_session_with_title(capsys):
"""new_session(title=...) creates a session and sets the title."""
cli = _make_cli()
cli._session_db = MagicMock()
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
cli.new_session(title="My Test Session")
# Assert set_session_title was called with the new session ID and sanitized title
cli._session_db.set_session_title.assert_called_once()
call_args = cli._session_db.set_session_title.call_args
assert call_args[0][0] == cli.session_id
assert call_args[0][1] == "My Test Session"
captured = capsys.readouterr()
assert "My Test Session" in captured.out
def test_new_session_with_duplicate_title_surfaces_error(capsys):
"""new_session(title=...) handles ValueError from a duplicate-title conflict.
The session is still created; the title assignment fails; the success banner
must not claim the rejected title as the session name.
"""
cli = _make_cli()
cli._session_db = MagicMock()
cli._session_db.set_session_title.side_effect = ValueError(
"Title 'Dup' is already in use by session abc-123"
)
cli.agent = _FakeAgent("old_session_id", datetime.now())
cli.conversation_history = []
# Capture warnings printed via cli._cprint. After importlib.reload(),
# the method's __globals__ dict is the one from the live module — patch
# the exact dict the method will read.
warnings: list[str] = []
method_globals = cli.new_session.__globals__
original = method_globals["_cprint"]
method_globals["_cprint"] = lambda msg: warnings.append(msg)
try:
cli.new_session(title="Dup")
finally:
method_globals["_cprint"] = original
cli._session_db.set_session_title.assert_called_once()
joined = "\n".join(warnings)
assert "already in use" in joined
assert "session started untitled" in joined
# The success banner must NOT claim the rejected title as the session name.
captured = capsys.readouterr()
assert "New session started: Dup" not in captured.out
assert "New session started!" in captured.out

View file

@ -1,107 +1,101 @@
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.
"""Tests for CLI/TUI CWD resolution in load_cli_config().
When the gateway resolves TERMINAL_CWD at startup and cli.py is later
imported lazily (via delegate_tool CLI_CONFIG), load_cli_config() must
not overwrite the already-resolved value with os.getcwd().
config.yaml terminal.cwd is the canonical source of truth.
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
See issue #10817.
Rules:
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
- Non-local with placeholder: pop cwd for backend default.
- Non-local with explicit path: keep as-is.
"""
import os
import pytest
# The sentinel values that mean "resolve at runtime"
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Simulate the CWD resolution logic from load_cli_config().
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
"""Mirror the CWD resolution logic from cli.py load_cli_config()."""
effective_backend = terminal_config.get("env_type", "local")
This mirrors the code in cli.py that checks for a pre-resolved
TERMINAL_CWD before falling back to os.getcwd().
"""
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_existing_cwd = env.get("TERMINAL_CWD", "")
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = _existing_cwd
else:
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
else:
terminal_config.pop("cwd", None)
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd"
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
terminal_config.pop("cwd", None)
# Simulate the bridging loop: write terminal_config["cwd"] to env
_file_has_terminal = defaults.get("_file_has_terminal", False)
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
if "cwd" in terminal_config:
if _file_has_terminal or "TERMINAL_CWD" not in env:
if _is_gateway:
pass # don't touch env
else:
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
return env.get("TERMINAL_CWD", "")
class TestLazyImportGuard:
"""TERMINAL_CWD resolved by gateway must survive a lazy cli.py import."""
class TestLocalBackendCli:
"""Local backend always uses os.getcwd()."""
def test_gateway_resolved_cwd_survives(self):
"""Gateway set TERMINAL_CWD → lazy cli import must not clobber."""
env = {"TERMINAL_CWD": "/home/user/workspace"}
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/home/user/workspace"
def test_gateway_resolved_cwd_survives_with_file_terminal(self):
"""Even when config.yaml has a terminal: section, resolved CWD survives."""
env = {"TERMINAL_CWD": "/home/user/workspace"}
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/home/user/workspace"
class TestConfigCwdResolution:
"""config.yaml terminal.cwd is the canonical source of truth."""
def test_explicit_config_cwd_wins(self):
"""terminal.cwd: /explicit/path always wins."""
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": "/explicit/path"}
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/explicit/path"
def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
def test_explicit_config_ignored(self):
env = {}
terminal_config = {"cwd": "."}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
tc = {"cwd": "/explicit/path", "env_type": "local"}
d = {"terminal": {"cwd": "/explicit/path"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
result = _resolve_terminal_cwd(terminal_config, defaults, env)
def test_inherited_env_overwritten(self):
env = {"TERMINAL_CWD": "/parent/hermes"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_placeholder_resolved(self):
env = {}
tc = {"cwd": "."}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
def test_env_and_no_config_file(self):
env = {"TERMINAL_CWD": "/stale/value"}
tc = {"cwd": ".", "env_type": "local"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == "/fake/getcwd"
class TestNonLocalBackends:
"""Non-local backends use config or per-backend defaults."""
def test_placeholder_popped(self):
env = {}
tc = {"cwd": ".", "env_type": "docker"}
d = {"terminal": {"cwd": "."}}
assert _resolve_cwd(tc, d, env) == ""
def test_explicit_path_kept(self):
env = {}
tc = {"cwd": "/srv/app", "env_type": "ssh"}
d = {"terminal": {"cwd": "/srv/app"}}
assert _resolve_cwd(tc, d, env) == "/srv/app"
def test_auto_placeholder_popped(self):
env = {}
tc = {"cwd": "auto", "env_type": "modal"}
d = {"terminal": {"cwd": "auto"}}
assert _resolve_cwd(tc, d, env) == ""
class TestGatewayLazyImport:
"""Gateway lazy import of cli.py must not clobber TERMINAL_CWD."""
def test_gateway_cwd_preserved(self):
env = {"_HERMES_GATEWAY": "1", "TERMINAL_CWD": "/home/user/project"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/home/user/project"
def test_cli_overwrites_stale_env(self):
env = {"TERMINAL_CWD": "/stale/from/dotenv"}
tc = {"cwd": "/home/user", "env_type": "local"}
d = {"terminal": {"cwd": "/home/user"}}
result = _resolve_cwd(tc, d, env)
assert result == "/fake/getcwd"
def test_remote_backend_pops_cwd(self):
"""Remote backend + placeholder cwd → popped for backend default."""
env = {}
terminal_config = {"cwd": ".", "env_type": "docker"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "" # cwd popped, no env var set
def test_remote_backend_with_prior_cwd_preserves(self):
"""Remote backend + pre-resolved TERMINAL_CWD → adopted."""
env = {"TERMINAL_CWD": "/project"}
terminal_config = {"cwd": ".", "env_type": "docker"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/project"

View file

@ -128,17 +128,34 @@ class TestPriorityProcessingModels(unittest.TestCase):
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_all_anthropic_models_supported(self):
"""Per Anthropic docs, fast mode is currently Opus 4.6 only.
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
Pre-fix this test asserted all Claude variants supported fast mode,
which mirrored the bug rather than the API contract.
"""
from hermes_cli.models import model_supports_fast_mode
# All Claude models support Anthropic Fast Mode — Opus, Sonnet, Haiku.
# Supported: Opus 4.6 in any form
supported = [
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
"claude-opus-4-6", "claude-opus-4.6",
"anthropic/claude-opus-4-6", "anthropic/claude-opus-4.6",
]
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
# Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku
unsupported = [
"claude-opus-4-7",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
]
for model in unsupported:
assert not model_supports_fast_mode(model), (
f"{model} should NOT support fast mode — Anthropic restricts "
f"speed=fast to Opus 4.6"
)
def test_codex_models_excluded(self):
"""Codex models route through Responses API and don't accept service_tier."""
from hermes_cli.models import model_supports_fast_mode
@ -257,18 +274,20 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("anthropic/claude-opus-4-6") is True
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_all_claude_models_supported(self):
def test_anthropic_non_opus46_models_excluded(self):
"""Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
"""
from hermes_cli.models import model_supports_fast_mode
# All Claude models support fast mode — Opus, Sonnet, Haiku.
# The anthropic adapter gates speed=fast on native Anthropic
# endpoints only, so third-party proxies that reject the beta
# are protected downstream (see _is_third_party_anthropic_endpoint).
assert model_supports_fast_mode("claude-sonnet-4-6") is True
assert model_supports_fast_mode("claude-sonnet-4.6") is True
assert model_supports_fast_mode("claude-haiku-4-5") is True
assert model_supports_fast_mode("claude-opus-4-7") is True
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is True
assert model_supports_fast_mode("claude-sonnet-4-6") is False
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
def test_non_claude_models_not_anthropic_fast(self):
"""Non-Claude models should not be treated as Anthropic fast-mode."""
@ -294,6 +313,17 @@ class TestAnthropicFastMode(unittest.TestCase):
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
"""Opus 4.7 and other Claude models don't support fast mode (API 400s).
Per Anthropic docs, fast mode is currently Opus 4.6 only.
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
def test_resolve_overrides_returns_service_tier_for_openai(self):
"""OpenAI models should still get service_tier, not speed."""
from hermes_cli.models import resolve_fast_mode_overrides
@ -302,13 +332,21 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
"""Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
assert _is_anthropic_fast_model("claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6") is True
assert _is_anthropic_fast_model("claude-sonnet-4-6") is True
assert _is_anthropic_fast_model("claude-haiku-4-5") is True
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
# Unsupported per Anthropic API contract — would 400 if we sent speed=fast
assert _is_anthropic_fast_model("claude-opus-4-7") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
# Non-Claude
assert _is_anthropic_fast_model("gpt-5.4") is False
assert _is_anthropic_fast_model("") is False
@ -320,14 +358,23 @@ class TestAnthropicFastMode(unittest.TestCase):
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
def test_fast_command_exposed_for_anthropic_sonnet(self):
"""Sonnet now supports Anthropic Fast Mode — the adapter gates on base_url."""
def test_fast_command_hidden_for_anthropic_sonnet(self):
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-sonnet-4-6", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is True
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
"""Opus 4.7 doesn't support fast mode — /fast must be hidden."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
model="claude-opus-4-7", agent=None,
)
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_non_claude_non_openai(self):
"""Non-Claude, non-OpenAI models should not expose /fast."""

View file

@ -647,6 +647,74 @@ class TestGetDueJobs:
assert get_due_jobs() == []
assert get_job("oneshot-stale")["next_run_at"] is None
def test_broken_cron_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
now = datetime(2026, 3, 18, 10, 0, 0, tzinfo=timezone.utc)
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
save_jobs(
[{
"id": "cron-recover",
"name": "AI Daily Digest",
"prompt": "...",
"schedule": {"kind": "cron", "expr": "0 12 * * *", "display": "0 12 * * *"},
"schedule_display": "0 12 * * *",
"repeat": {"times": None, "completed": 0},
"enabled": True,
"state": "scheduled",
"paused_at": None,
"paused_reason": None,
"created_at": "2026-03-18T09:00:00+00:00",
"next_run_at": None,
"last_run_at": None,
"last_status": None,
"last_error": None,
"deliver": "local",
"origin": None,
}]
)
assert get_due_jobs() == []
recovered = get_job("cron-recover")["next_run_at"]
assert recovered is not None
recovered_dt = datetime.fromisoformat(recovered)
if recovered_dt.tzinfo is None:
recovered_dt = recovered_dt.replace(tzinfo=timezone.utc)
assert recovered_dt > now
def test_broken_interval_without_next_run_is_recovered(self, tmp_cron_dir, monkeypatch):
now = datetime(2026, 3, 18, 10, 0, 0, tzinfo=timezone.utc)
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
save_jobs(
[{
"id": "interval-recover",
"name": "Hourly heartbeat",
"prompt": "...",
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
"schedule_display": "every 1h",
"repeat": {"times": None, "completed": 0},
"enabled": True,
"state": "scheduled",
"paused_at": None,
"paused_reason": None,
"created_at": "2026-03-18T09:00:00+00:00",
"next_run_at": None,
"last_run_at": None,
"last_status": None,
"last_error": None,
"deliver": "local",
"origin": None,
}]
)
assert get_due_jobs() == []
recovered = get_job("interval-recover")["next_run_at"]
assert recovered is not None
recovered_dt = datetime.fromisoformat(recovered)
if recovered_dt.tzinfo is None:
recovered_dt = recovered_dt.replace(tzinfo=timezone.utc)
assert recovered_dt > now
class TestEnabledToolsets:
def test_enabled_toolsets_stored(self, tmp_cron_dir):

View file

@ -1857,6 +1857,54 @@ class TestBuildJobPromptMissingSkill:
assert "go" in result
class TestBuildJobPromptBumpUse:
"""Verify that cron jobs bump skill usage counters so the curator sees them as active."""
def test_bump_use_called_for_loaded_skill(self):
"""bump_use is called for each successfully loaded skill."""
def _skill_view(name: str) -> str:
return json.dumps({"success": True, "content": f"Content for {name}."})
with patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
patch("tools.skill_usage.bump_use") as mock_bump:
_build_job_prompt({"skills": ["alpha", "beta"], "prompt": "go"})
assert mock_bump.call_count == 2
calls = [c[0][0] for c in mock_bump.call_args_list]
assert "alpha" in calls
assert "beta" in calls
def test_bump_use_not_called_for_missing_skill(self):
"""bump_use is NOT called when a skill fails to load."""
def _missing_view(name: str) -> str:
return json.dumps({"success": False, "error": "not found"})
with patch("tools.skills_tool.skill_view", side_effect=_missing_view), \
patch("tools.skill_usage.bump_use") as mock_bump:
_build_job_prompt({"skills": ["ghost"], "prompt": "go"})
assert mock_bump.call_count == 0
def test_bump_failure_does_not_break_prompt(self, caplog):
"""If bump_use raises, the prompt still builds — error is logged at DEBUG."""
def _skill_view(name: str) -> str:
return json.dumps({"success": True, "content": "Works."})
with patch("tools.skills_tool.skill_view", side_effect=_skill_view), \
patch("tools.skill_usage.bump_use", side_effect=RuntimeError("boom")), \
caplog.at_level(logging.DEBUG, logger="cron.scheduler"):
result = _build_job_prompt({"skills": ["good-skill"], "prompt": "go"})
# Prompt should still contain the skill content and original instruction
assert "Works." in result
assert "go" in result
# The error should be logged at DEBUG level, not crash
assert any("failed to bump" in r.message for r in caplog.records)
class TestSendMediaViaAdapter:
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""

View file

@ -138,6 +138,29 @@ class TestSlashCommands:
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "compress" in response_text.lower() or "context" in response_text.lower()
@pytest.mark.asyncio
async def test_quick_command_alias_targets_builtin_command_with_args(
self, adapter, runner, platform
):
"""Alias targets with args must reach the built-in command handler."""
runner.config.quick_commands = {
"s": {"type": "alias", "target": "/status extra-arg"}
}
async def _handle_status(event):
assert event.get_command_args() == "extra-arg"
return "status via alias"
runner._handle_status_command = AsyncMock(side_effect=_handle_status)
send = await send_and_capture(adapter, "/s", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert response_text == "status via alias"
runner._handle_status_command.assert_awaited_once()
runner._handle_message_with_agent.assert_not_awaited()
class TestSessionLifecycle:
"""Verify session state changes across command sequences."""

View file

@ -240,6 +240,48 @@ class TestAdapterInit:
"http://127.0.0.1:3000",
)
def test_invalid_port_from_env_falls_back_to_default(self, monkeypatch):
monkeypatch.setenv("API_SERVER_PORT", "not-a-port")
config = PlatformConfig(enabled=True)
adapter = APIServerAdapter(config)
assert adapter._port == 8642
def test_create_agent_forwards_config_reasoning_effort(self, monkeypatch):
captured = {}
class FakeAgent:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("run_agent.AIAgent", FakeAgent)
monkeypatch.setattr(
"gateway.run._resolve_runtime_agent_kwargs",
lambda: {
"provider": "openai-codex",
"base_url": "https://example.test/v1",
"api_mode": "codex_responses",
},
)
monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5.5")
monkeypatch.setattr(
"gateway.run._load_gateway_config",
lambda: {"agent": {"reasoning_effort": "xhigh"}},
)
monkeypatch.setattr(
"gateway.run.GatewayRunner._load_reasoning_config",
staticmethod(lambda: {"enabled": True, "effort": "xhigh"}),
)
monkeypatch.setattr("gateway.run.GatewayRunner._load_fallback_model", staticmethod(lambda: None))
monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set())
adapter = APIServerAdapter(PlatformConfig(enabled=True))
monkeypatch.setattr(adapter, "_ensure_session_db", lambda: None)
agent = adapter._create_agent(session_id="api-session")
assert isinstance(agent, FakeAgent)
assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"}
# ---------------------------------------------------------------------------
# Auth checking

View file

@ -0,0 +1,78 @@
"""Gateway command help rendering tests."""
import pytest
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_event(text: str, platform: Platform) -> MessageEvent:
return MessageEvent(
text=text,
source=SessionSource(
platform=platform,
chat_id="chat-1",
user_id="user-1",
user_name="tester",
chat_type="dm",
),
)
def _make_runner():
from gateway.run import GatewayRunner
return object.__new__(GatewayRunner)
@pytest.mark.asyncio
async def test_help_sanitizes_slash_command_mentions_for_telegram(monkeypatch):
"""Telegram help output must not expose invalid uppercase/hyphenated slashes."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {
"/Linear": {"description": "Open Linear"},
"/Custom-Thing": {"description": "Run a custom thing"},
},
)
result = await _make_runner()._handle_help_command(
_make_event("/help", Platform.TELEGRAM)
)
assert "`/linear`" in result
assert "`/custom_thing`" in result
assert "`/Linear`" not in result
assert "`/Custom-Thing`" not in result
@pytest.mark.asyncio
async def test_commands_sanitizes_slash_command_mentions_for_telegram(monkeypatch):
"""Paginated Telegram /commands output uses Telegram-valid slash mentions."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {"/Linear": {"description": "Open Linear"}},
)
result = await _make_runner()._handle_commands_command(
_make_event("/commands 999", Platform.TELEGRAM)
)
assert "`/linear`" in result
assert "`/Linear`" not in result
@pytest.mark.asyncio
async def test_help_keeps_non_telegram_slash_command_mentions_unchanged(monkeypatch):
"""Only Telegram needs slash mentions rewritten to Telegram command names."""
monkeypatch.setattr(
"agent.skill_commands.get_skill_commands",
lambda: {"/Linear": {"description": "Open Linear"}},
)
result = await _make_runner()._handle_help_command(
_make_event("/help", Platform.DISCORD)
)
assert "`/Linear`" in result

View file

@ -191,6 +191,50 @@ class TestVoiceAttachmentSSRFProtection:
assert kwargs.get("follow_redirects") is True
assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard]
# ---------------------------------------------------------------------------
# WebSocket proxy handling
# ---------------------------------------------------------------------------
class TestQQWebSocketProxy:
@pytest.mark.asyncio
async def test_open_ws_honors_proxy_env(self, monkeypatch):
from gateway.platforms.qqbot import QQAdapter
for key in (
"WSS_PROXY",
"wss_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
adapter = QQAdapter(_make_config(app_id="a", client_secret="b"))
seen_session_kwargs = {}
seen_ws_kwargs = {}
class FakeSession:
def __init__(self, **kwargs):
seen_session_kwargs.update(kwargs)
self.closed = False
async def close(self):
self.closed = True
async def ws_connect(self, *args, **kwargs):
seen_ws_kwargs.update(kwargs)
return mock.AsyncMock(closed=False)
with mock.patch("gateway.platforms.qqbot.adapter.aiohttp.ClientSession", side_effect=FakeSession):
await adapter._open_ws("wss://api.sgroup.qq.com/websocket")
assert seen_session_kwargs.get("trust_env") is True
assert seen_ws_kwargs.get("proxy") == "http://127.0.0.1:7897"
# ---------------------------------------------------------------------------
# _strip_at_mention
# ---------------------------------------------------------------------------

View file

@ -124,6 +124,10 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
runner, session_key = _make_resume_runner()
other_key = "agent:main:telegram:dm:other-chat"
runner._pending_skills_reload_notes = {
session_key: "[USER INITIATED SKILLS RELOAD: target]",
other_key: "[USER INITIATED SKILLS RELOAD: other]",
}
approve_session(session_key, "recursive delete")
approve_session(other_key, "recursive delete")
enable_session_yolo(session_key)
@ -140,10 +144,12 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
@pytest.mark.asyncio
@ -151,6 +157,10 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
runner, session_key = _make_branch_runner()
other_key = "agent:main:telegram:dm:other-chat"
runner._pending_skills_reload_notes = {
session_key: "[USER INITIATED SKILLS RELOAD: target]",
other_key: "[USER INITIATED SKILLS RELOAD: other]",
}
approve_session(session_key, "recursive delete")
approve_session(other_key, "recursive delete")
enable_session_yolo(session_key)
@ -167,10 +177,12 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
@pytest.mark.asyncio
@ -216,6 +228,7 @@ def test_clear_session_boundary_security_state_is_scoped():
runner = object.__new__(GatewayRunner)
runner._pending_approvals = {}
runner._update_prompt_pending = {}
runner._pending_skills_reload_notes = {}
source = _make_source()
session_key = build_session_key(source)
@ -229,6 +242,12 @@ def test_clear_session_boundary_security_state_is_scoped():
runner._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
runner._update_prompt_pending[session_key] = True
runner._update_prompt_pending[other_key] = True
runner._pending_skills_reload_notes[session_key] = (
"[USER INITIATED SKILLS RELOAD: target]"
)
runner._pending_skills_reload_notes[other_key] = (
"[USER INITIATED SKILLS RELOAD: other]"
)
runner._clear_session_boundary_security_state(session_key)
@ -237,16 +256,19 @@ def test_clear_session_boundary_security_state_is_scoped():
assert is_session_yolo_enabled(session_key) is False
assert session_key not in runner._pending_approvals
assert session_key not in runner._update_prompt_pending
assert session_key not in runner._pending_skills_reload_notes
# Other session untouched
assert is_approved(other_key, "recursive delete") is True
assert is_session_yolo_enabled(other_key) is True
assert other_key in runner._pending_approvals
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
# Empty session_key is a no-op
runner._clear_session_boundary_security_state("")
assert is_approved(other_key, "recursive delete") is True
assert other_key in runner._update_prompt_pending
assert other_key in runner._pending_skills_reload_notes
def test_clear_session_boundary_security_state_wakes_blocked_approvals():

View file

@ -169,9 +169,9 @@ class TestSmsRequirements:
class TestWebhookHostConfig:
"""Verify SMS_WEBHOOK_HOST env var and default."""
def test_default_host_is_all_interfaces(self):
def test_default_host_is_localhost(self):
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
assert DEFAULT_WEBHOOK_HOST == "0.0.0.0"
assert DEFAULT_WEBHOOK_HOST == "127.0.0.1"
def test_host_from_env(self):
from gateway.platforms.sms import SmsAdapter
@ -242,6 +242,48 @@ class TestStartupGuard:
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_missing_webhook_url_is_non_retryable(self):
adapter = self._make_adapter()
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert "sms_missing_webhook_url" == adapter.fatal_error_code
@pytest.mark.asyncio
async def test_missing_phone_number_is_non_retryable(self):
from gateway.platforms.sms import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
"TWILIO_AUTH_TOKEN": "tok",
"TWILIO_PHONE_NUMBER": "",
"SMS_WEBHOOK_URL": "",
}
with patch.dict(os.environ, env, clear=True):
pc = PlatformConfig(enabled=True, api_key="tok")
adapter = SmsAdapter(pc)
await adapter.connect()
assert adapter.has_fatal_error is True
assert adapter.fatal_error_retryable is False
assert adapter.fatal_error_code == "sms_missing_phone_number"
@pytest.mark.asyncio
async def test_insecure_flag_does_not_set_fatal_error(self):
mock_session = AsyncMock()
with patch.dict(os.environ, {"SMS_INSECURE_NO_SIGNATURE": "true"}), \
patch("aiohttp.web.AppRunner") as mock_runner_cls, \
patch("aiohttp.web.TCPSite") as mock_site_cls, \
patch("aiohttp.ClientSession", return_value=mock_session):
mock_runner_cls.return_value.setup = AsyncMock()
mock_runner_cls.return_value.cleanup = AsyncMock()
mock_site_cls.return_value.start = AsyncMock()
adapter = self._make_adapter()
result = await adapter.connect()
assert result is True
assert adapter.has_fatal_error is False
await adapter.disconnect()
@pytest.mark.asyncio
async def test_insecure_flag_allows_start_without_url(self):
mock_session = AsyncMock()

View file

@ -313,9 +313,33 @@ class TestTeamsPluginRegistration:
# ---------------------------------------------------------------------------
# Tests: Connect / Disconnect
# Tests: Interactive setup (import fix regression — #18325 / #19173)
# ---------------------------------------------------------------------------
class TestTeamsInteractiveSetup:
def test_interactive_setup_persists_credentials(self, tmp_path, monkeypatch):
"""Regression for #19173: interactive_setup must import prompt helpers
from hermes_cli.cli_output (not hermes_cli.config) and persist
credentials to .env without crashing.
"""
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_cli.cli_output as cli_output_mod
answers = iter(["client-id", "client-secret", "tenant-id", "aad-1, aad-2"])
monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(answers))
monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: True)
monkeypatch.setattr(cli_output_mod, "print_info", lambda *_a, **_kw: None)
monkeypatch.setattr(cli_output_mod, "print_success", lambda *_a, **_kw: None)
monkeypatch.setattr(cli_output_mod, "print_warning", lambda *_a, **_kw: None)
_teams_mod.interactive_setup()
env_text = (hermes_home / ".env").read_text(encoding="utf-8")
assert "TEAMS_CLIENT_ID=client-id" in env_text
assert "TEAMS_TENANT_ID=tenant-id" in env_text
class TestTeamsConnect:
@pytest.mark.asyncio
async def test_connect_fails_without_sdk(self, monkeypatch):

View file

@ -261,6 +261,57 @@ def test_group_allow_from_is_enforced_by_gateway_authorization_not_trigger_gate(
assert adapter._should_process_message(_group_message("hello", from_user_id=333)) is True
def test_top_level_require_mention_bridges_to_telegram(monkeypatch, tmp_path):
"""require_mention at the config.yaml top level (alongside group_sessions_per_user)
must behave identically to telegram.require_mention: true (#3979).
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Intentionally no "telegram:" section — keys are at the top level.
(hermes_home / "config.yaml").write_text(
"require_mention: true\n"
"group_sessions_per_user: true\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
config = load_gateway_config()
assert config is not None
assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "true"
# The adapter's extra dict must also carry the setting so that
# _telegram_require_mention() works even without the env var.
tg_cfg = config.platforms.get(__import__("gateway.config", fromlist=["Platform"]).Platform.TELEGRAM)
if tg_cfg is not None:
assert tg_cfg.extra.get("require_mention") is True
def test_top_level_require_mention_does_not_override_telegram_section(monkeypatch, tmp_path):
"""When telegram.require_mention is explicitly set, top-level require_mention
must not override it (platform-specific config takes precedence).
"""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "config.yaml").write_text(
"require_mention: true\n"
"telegram:\n"
" require_mention: false\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("TELEGRAM_REQUIRE_MENTION", raising=False)
config = load_gateway_config()
assert config is not None
# The telegram-specific "false" must win over the top-level "true".
assert __import__("os").environ.get("TELEGRAM_REQUIRE_MENTION") == "false"
def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()

View file

@ -5,11 +5,12 @@ across all gateway messenger platforms.
"""
import os
from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
@ -206,3 +207,152 @@ class TestTitleInHelp:
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"title"' in source
# ---------------------------------------------------------------------------
# /new with title
# ---------------------------------------------------------------------------
class TestResetCommandWithTitle:
"""Tests for GatewayRunner._handle_reset_command with a title argument."""
@pytest.mark.asyncio
async def test_reset_command_with_title(self):
"""Sending /new <title> resets session and sets the title."""
from datetime import datetime
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
session_key = build_session_key(source)
new_session_entry = SessionEntry(
session_key=session_key,
session_id="sess-new",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = new_session_entry
runner.session_store.reset_session.return_value = new_session_entry
runner.session_store._entries = {session_key: new_session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = MagicMock()
runner._agent_cache = {}
runner._agent_cache_lock = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
event = _make_event(text="/new Custom Name")
result = await runner._handle_reset_command(event)
runner.session_store.reset_session.assert_called_once()
runner._session_db.set_session_title.assert_called_once_with(
"sess-new", "Custom Name"
)
# Header reflects the applied title
assert "Custom Name" in str(result)
@pytest.mark.asyncio
async def test_reset_command_duplicate_title_surfaces_warning(self):
"""/new <title> with an already-in-use title returns a warning in the reply."""
from datetime import datetime
from gateway.run import GatewayRunner
from gateway.session import SessionEntry, SessionSource, build_session_key
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
session_key = build_session_key(source)
new_session_entry = SessionEntry(
session_key=session_key,
session_id="sess-new",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = new_session_entry
runner.session_store.reset_session.return_value = new_session_entry
runner.session_store._entries = {session_key: new_session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = MagicMock()
runner._session_db.set_session_title.side_effect = ValueError(
"Title 'Dup' is already in use by session abc-123"
)
runner._agent_cache = {}
runner._agent_cache_lock = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
event = _make_event(text="/new Dup")
result = await runner._handle_reset_command(event)
runner._session_db.set_session_title.assert_called_once()
reply = str(result)
assert "already in use" in reply
assert "session started untitled" in reply
# Header must NOT claim the rejected title as the session name
assert "New session started: Dup" not in reply
# ---------------------------------------------------------------------------
# /new in help output
# ---------------------------------------------------------------------------
class TestNewInHelp:
"""Verify /new appears in help text with the [name] args hint."""
def test_new_command_in_help_output(self):
"""The gateway help output includes /new with the [name] hint."""
from hermes_cli.commands import gateway_help_lines
lines = gateway_help_lines()
new_line = next((line for line in lines if line.startswith("`/new ")), None)
assert new_line is not None
assert "[name]" in new_line

View file

@ -954,6 +954,46 @@ class TestVoiceChannelCommands:
assert "Test transcript" in msg
assert "42" in msg # user_id in mention
@pytest.mark.asyncio
async def test_input_suppresses_duplicate_transcript(self, runner):
"""Near-immediate duplicate STT output should not dispatch twice."""
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
mock_adapter.handle_message = AsyncMock()
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
await runner._handle_voice_channel_input(111, 42, "Hello from VC")
mock_adapter.handle_message.assert_called_once()
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
async def test_input_suppresses_near_duplicate_transcript(self, runner):
"""Small STT wording drift should still be treated as the same utterance."""
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter._voice_text_channels = {111: 123}
mock_adapter._voice_sources = {}
mock_channel = AsyncMock()
mock_adapter._client = MagicMock()
mock_adapter._client.get_channel = MagicMock(return_value=mock_channel)
mock_adapter.handle_message = AsyncMock()
runner.adapters[Platform.DISCORD] = mock_adapter
await runner._handle_voice_channel_input(111, 42, "This is a test of the voice system")
await runner._handle_voice_channel_input(111, 42, "This is a test for the voice system")
mock_adapter.handle_message.assert_called_once()
mock_channel.send.assert_called_once()
# -- _get_guild_id --
def test_get_guild_id_from_guild(self, runner):

View file

@ -36,6 +36,11 @@ class TestWeComRequirements:
class TestWeComAdapterInit:
def test_declares_non_editable_message_capability(self):
from gateway.platforms.wecom import WeComAdapter
assert WeComAdapter.SUPPORTS_MESSAGE_EDITING is False
def test_reads_config_from_extra(self):
from gateway.platforms.wecom import WeComAdapter

View file

@ -5,7 +5,7 @@ import base64
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
from gateway.config import PlatformConfig
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
@ -788,3 +788,43 @@ class TestIsStaleSessionRet:
def test_success_codes_are_not_stale(self):
assert weixin._is_stale_session_ret(0, 0, "") is False
assert weixin._is_stale_session_ret(None, None, "unknown error") is False
class TestWeixinContentDedup:
"""Regression tests for Issue #16182 — upstream API sends duplicate content
with different message_ids, bypassing message_id deduplication.
"""
def test_duplicate_content_with_different_message_ids_is_dropped(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
base_msg = {
"from_user_id": "wxid_user1",
"item_list": [{"type": 1, "text_item": {"text": "hello world"}}],
}
asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-1"}))
asyncio.run(adapter._process_message({**base_msg, "message_id": "msg-2"}))
assert adapter.handle_message.await_count == 1
event = adapter.handle_message.await_args[0][0]
assert event.text == "hello world"
def test_content_dedup_not_called_for_messages_without_text(self):
adapter = _make_adapter()
adapter._poll_session = object()
adapter.handle_message = AsyncMock()
adapter._dedup.is_duplicate = Mock(return_value=False)
empty_msg = {
"from_user_id": "wxid_user1",
"message_id": "msg-1",
"item_list": [],
}
asyncio.run(adapter._process_message(empty_msg))
assert adapter.handle_message.await_count == 0
# is_duplicate should only be called for message_id, never for content
assert all("content:" not in str(call) for call in adapter._dedup.is_duplicate.call_args_list)

View file

@ -896,3 +896,286 @@ def test_refresh_non_reuse_error_keeps_original_description():
assert "Refresh session has been revoked" in str(exc_info.value)
# Must not have been rewritten with the reuse message.
assert "external process" not in str(exc_info.value).lower()
# =============================================================================
# Shared Nous token store — cross-profile persistence (Codex-style auto-import)
# =============================================================================
@pytest.fixture
def shared_store_env(tmp_path, monkeypatch):
"""Redirect HERMES_SHARED_AUTH_DIR to a tmp_path.
Required for every test that exercises the shared Nous store the
in-auth.py seat belt refuses to touch the real user's shared store
under pytest, so tests that forget this fixture fail loudly instead
of corrupting real state.
"""
shared_dir = tmp_path / "shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(shared_dir))
return shared_dir
def test_shared_store_seat_belt_refuses_real_home_under_pytest(monkeypatch):
"""Without HERMES_SHARED_AUTH_DIR override, the seat belt must trip.
Mirrors the existing ``_auth_file_path`` seat belt: forgetting to
redirect this store in a test must fail loudly instead of silently
writing to the user's real ``~/.hermes/shared/`` across CI runs.
"""
from hermes_cli.auth import _nous_shared_store_path
monkeypatch.delenv("HERMES_SHARED_AUTH_DIR", raising=False)
with pytest.raises(RuntimeError, match="shared Nous auth store"):
_nous_shared_store_path()
def test_shared_store_honors_env_override(tmp_path, monkeypatch):
"""HERMES_SHARED_AUTH_DIR must redirect the path."""
from hermes_cli.auth import _nous_shared_store_path, NOUS_SHARED_STORE_FILENAME
custom_dir = tmp_path / "custom_shared"
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(custom_dir))
path = _nous_shared_store_path()
assert path == custom_dir / NOUS_SHARED_STORE_FILENAME
def test_shared_store_read_missing_returns_none(shared_store_env):
"""Missing file → ``_read_shared_nous_state()`` returns None."""
from hermes_cli.auth import _read_shared_nous_state
assert _read_shared_nous_state() is None
def test_shared_store_read_malformed_returns_none(shared_store_env):
"""Unreadable / non-JSON file → None, not an exception."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{ not json")
assert _read_shared_nous_state() is None
def test_shared_store_read_missing_required_fields_returns_none(shared_store_env):
"""Payload without refresh_token → None (nothing worth importing)."""
from hermes_cli.auth import _nous_shared_store_path, _read_shared_nous_state
path = _nous_shared_store_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"_schema": 1, "access_token": "abc"}))
assert _read_shared_nous_state() is None
def test_shared_store_write_and_read_roundtrip(shared_store_env):
"""Write → read must preserve refresh_token + OAuth URLs."""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
_write_shared_nous_state,
)
_write_shared_nous_state(_full_state_fixture())
path = _nous_shared_store_path()
assert path.is_file()
# Permissions should be 0600 where the platform supports it.
mode = path.stat().st_mode & 0o777
assert mode == 0o600 or mode == 0o644 # 0o644 on platforms without chmod
loaded = _read_shared_nous_state()
assert loaded is not None
assert loaded["refresh_token"] == "refresh-tok"
assert loaded["access_token"] == "access-tok"
assert loaded["portal_base_url"] == "https://portal.example.com"
assert loaded["inference_base_url"] == "https://inference.example.com/v1"
# Volatile agent_key MUST NOT be persisted to the shared store
# (24h TTL, profile-specific — only long-lived OAuth tokens are
# cross-profile useful).
assert "agent_key" not in loaded
def test_shared_store_write_skips_when_refresh_token_missing(shared_store_env):
"""Write is a no-op when refresh_token is absent (nothing to share)."""
from hermes_cli.auth import _nous_shared_store_path, _write_shared_nous_state
state = dict(_full_state_fixture())
state["refresh_token"] = ""
_write_shared_nous_state(state)
assert not _nous_shared_store_path().is_file()
def test_persist_nous_credentials_mirrors_to_shared_store(
tmp_path, monkeypatch, shared_store_env,
):
"""persist_nous_credentials must populate BOTH per-profile auth.json
AND the shared store, so a future profile's `hermes auth add nous
--type oauth` can one-tap import instead of redoing device-code.
"""
from hermes_cli.auth import (
_nous_shared_store_path,
_read_shared_nous_state,
persist_nous_credentials,
)
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
persist_nous_credentials(_full_state_fixture())
# Per-profile auth.json populated
payload = json.loads((hermes_home / "auth.json").read_text())
assert "nous" in payload.get("providers", {})
# Shared store populated with the same refresh_token
shared = _read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# Shared file path lives under the tmp override, NOT the real home
assert str(_nous_shared_store_path()).startswith(str(shared_store_env))
def test_try_import_shared_returns_none_when_store_missing(shared_store_env):
"""No shared store → no rehydrate (fall through to device-code)."""
from hermes_cli.auth import _try_import_shared_nous_state
assert _try_import_shared_nous_state() is None
def test_try_import_shared_returns_none_on_refresh_failure(
shared_store_env, monkeypatch,
):
"""If the portal rejects the stored refresh_token (revoked, expired,
portal down), _try_import_shared_nous_state must return None so the
login flow falls back to a fresh device-code run.
"""
from hermes_cli import auth as auth_mod
# Seed the shared store
auth_mod._write_shared_nous_state(_full_state_fixture())
# Make refresh fail
def _boom(*_args, **_kwargs):
raise AuthError(
"Refresh session has been revoked",
provider="nous",
code="invalid_grant",
relogin_required=True,
)
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _boom)
assert auth_mod._try_import_shared_nous_state() is None
def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch):
"""Happy path: stored refresh_token is accepted, forced refresh+mint
returns a fresh access_token + agent_key, and the returned dict has
every field persist_nous_credentials() needs.
"""
from hermes_cli import auth as auth_mod
auth_mod._write_shared_nous_state(_full_state_fixture())
def _fake_refresh(state, **kwargs):
# Simulate portal returning fresh tokens + a new agent_key
assert kwargs.get("force_refresh") is True
assert kwargs.get("force_mint") is True
return {
**state,
"access_token": "fresh-access-tok",
"refresh_token": "fresh-refresh-tok", # rotated
"agent_key": "new-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
assert result["access_token"] == "fresh-access-tok"
assert result["refresh_token"] == "fresh-refresh-tok"
assert result["agent_key"] == "new-agent-key"
# Preserved from shared state
assert result["portal_base_url"] == "https://portal.example.com"
assert result["client_id"] == "hermes-cli"
def test_shared_store_survives_across_profile_switch(
tmp_path, monkeypatch, shared_store_env,
):
"""End-to-end: profile A logs in → shared store populated → profile B
(different HERMES_HOME) sees the same shared state and can rehydrate
without re-running device-code.
"""
from hermes_cli import auth as auth_mod
# Profile A: login, which mirrors to shared store
profile_a = tmp_path / "profile_a"
profile_a.mkdir(parents=True, exist_ok=True)
(profile_a / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_a))
auth_mod.persist_nous_credentials(_full_state_fixture())
# Profile A's auth.json has nous
a_payload = json.loads((profile_a / "auth.json").read_text())
assert "nous" in a_payload.get("providers", {})
# Profile B: fresh HERMES_HOME, no auth yet, but the shared store
# persists — _read_shared_nous_state() must still return the tokens.
profile_b = tmp_path / "profile_b"
profile_b.mkdir(parents=True, exist_ok=True)
(profile_b / "auth.json").write_text(
json.dumps({"version": 1, "providers": {}})
)
monkeypatch.setenv("HERMES_HOME", str(profile_b))
# B's own auth.json has no nous
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" not in b_payload.get("providers", {})
# But the shared store is visible
shared = auth_mod._read_shared_nous_state()
assert shared is not None
assert shared["refresh_token"] == "refresh-tok"
# And a successful rehydrate + persist lands nous into profile B
def _fake_refresh(state, **kwargs):
return {
**state,
"access_token": "b-access-tok",
"refresh_token": "b-refresh-tok",
"agent_key": "b-agent-key",
"agent_key_expires_at": "2026-04-19T22:00:00+00:00",
}
monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh)
result = auth_mod._try_import_shared_nous_state()
assert result is not None
auth_mod.persist_nous_credentials(result)
b_payload = json.loads((profile_b / "auth.json").read_text())
assert "nous" in b_payload.get("providers", {})
assert b_payload["providers"]["nous"]["refresh_token"] == "b-refresh-tok"
# Shared store was updated with the rotated refresh_token too
shared_after = auth_mod._read_shared_nous_state()
assert shared_after is not None
assert shared_after["refresh_token"] == "b-refresh-tok"

View file

@ -471,6 +471,32 @@ class TestImport:
with pytest.raises(SystemExit):
run_import(args)
@pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only")
def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch):
"""Secret files must end up at 0600 after restore (zipfile drops mode bits)."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
zip_path = tmp_path / "backup.zip"
self._make_backup_zip(zip_path, {
"config.yaml": "model: openrouter\n",
".env": "OPENROUTER_API_KEY=sk-secret\n",
"auth.json": '{"providers": {"nous": "token"}}',
"state.db": b"SQLite format 3\x00",
"profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n",
})
args = Namespace(zipfile=str(zip_path), force=True)
from hermes_cli.backup import run_import
run_import(args)
for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"):
mode = (hermes_home / rel).stat().st_mode & 0o777
assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600"
# ---------------------------------------------------------------------------
# Round-trip test
@ -1348,6 +1374,53 @@ class TestPreUpdateBackup:
from hermes_cli.backup import create_pre_update_backup
assert create_pre_update_backup(hermes_home=tmp_path / "does-not-exist") is None
def test_keep_zero_does_not_delete_freshly_created_backup(self, hermes_home):
"""Regression: ``backup_keep: 0`` previously triggered ``backups[0:]``
in the pruner wiping the just-created zip and leaving the user
with no recovery point. The floor (keep>=1) preserves the new file
regardless of misconfiguration; users who don't want backups should
set ``pre_update_backup: false`` instead.
"""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=0)
assert out is not None
assert out.exists(), (
"keep=0 silently deleted the freshly-created backup; floor "
"should preserve the just-written file."
)
def test_keep_negative_does_not_delete_freshly_created_backup(self, hermes_home):
"""Mirror coverage: any value <1 should be floored, not literally
applied as a slice index."""
from hermes_cli.backup import create_pre_update_backup
out = create_pre_update_backup(hermes_home=hermes_home, keep=-3)
assert out is not None
assert out.exists()
def test_keep_zero_still_prunes_older_backups(self, hermes_home):
"""The floor preserves the new backup but should NOT regress the
rotation behaviour for older zips: a third call with keep=0 must
still remove pre-existing backups beyond the (floored) limit of 1.
"""
import time as _t
from hermes_cli.backup import create_pre_update_backup
first = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
second = create_pre_update_backup(hermes_home=hermes_home, keep=5)
_t.sleep(1.05)
third = create_pre_update_backup(hermes_home=hermes_home, keep=0)
remaining = {
p.name for p in (hermes_home / "backups").iterdir()
if p.name.startswith("pre-update-")
}
assert third.name in remaining, "Floor must preserve the new backup"
assert first.name not in remaining and second.name not in remaining, (
f"keep=0 floor of 1 should still prune older backups; "
f"remaining={remaining}"
)
class TestRunPreUpdateBackup:
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —

View file

@ -236,6 +236,13 @@ class TestTelegramBotCommands:
tg_name = cmd.name.replace("-", "_")
assert tg_name not in names
def test_excludes_commands_with_required_args(self):
names = {name for name, _ in telegram_bot_commands()}
assert "background" not in names
assert "queue" not in names
assert "steer" not in names
assert "background" in GATEWAY_KNOWN_COMMANDS
class TestSlackSubcommandMap:
def test_returns_dict(self):
@ -1661,6 +1668,19 @@ class TestPluginCommandEnumeration:
names = {name for name, _desc in telegram_bot_commands()}
assert "metricas" in names
def test_plugin_command_with_required_args_excluded_from_telegram_menu(self, monkeypatch):
"""Telegram BotCommand selections cannot supply required arguments."""
self._patch_plugin_commands(monkeypatch, {
"background-job": {
"handler": lambda _a: "ok",
"description": "Run a background job",
"args_hint": "<prompt>",
"plugin": "jobs-plugin",
}
})
names = {name for name, _desc in telegram_bot_commands()}
assert "background_job" not in names
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
"""/hermes metricas must route through the Slack subcommand map."""
self._patch_plugin_commands(monkeypatch, {

Some files were not shown because too many files have changed in this diff Show more