mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
ca8f2c7907
182 changed files with 9843 additions and 974 deletions
|
|
@ -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/
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
145
agent/curator.py
145
agent/curator.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 — "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
131
cli.py
|
|
@ -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
|
||||
|
|
|
|||
21
cron/jobs.py
21
cron/jobs.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
||||
|
|
|
|||
392
gateway/run.py
392
gateway/run.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; 1–64 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 dispatcher→worker 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():
|
||||
|
|
|
|||
|
|
@ -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'",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)``.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
|
|
@ -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); },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
451
plugins/kanban/dashboard/dist/index.js
vendored
451
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -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.
|
||||
|
|
|
|||
93
plugins/kanban/dashboard/dist/style.css
vendored
93
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
91
run_agent.py
91
run_agent.py
|
|
@ -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}",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/gateway/test_gateway_command_help.py
Normal file
78
tests/gateway/test_gateway_command_help.py
Normal 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
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue