Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
@@ -25,3 +25,7 @@ ui-tui/packages/hermes-ink/dist/
|
|||||||
|
|
||||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||||
|
hermes-config/
|
||||||
|
runtime/
|
||||||
|
|||||||
@@ -282,7 +282,16 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
|||||||
|
|
||||||
## Adding New Tools
|
## 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`:**
|
**1. Create `tools/your_tool.py`:**
|
||||||
```python
|
```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.
|
# 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.
|
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
|
||||||
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
|
_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 ───────────────────────
|
# ── Max output token limits per Anthropic model ───────────────────────
|
||||||
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
|
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
|
||||||
@@ -105,6 +106,9 @@ _ANTHROPIC_OUTPUT_LIMITS = {
|
|||||||
"claude-3-haiku": 4_096,
|
"claude-3-haiku": 4_096,
|
||||||
# Third-party Anthropic-compatible providers
|
# Third-party Anthropic-compatible providers
|
||||||
"minimax": 131_072,
|
"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.
|
# 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)
|
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).
|
# 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
|
# 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
|
# 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
|
``keep_nullable_hint=False`` because the Anthropic validator does not
|
||||||
recognize the OpenAPI-style ``nullable: true`` extension and strict
|
recognize the OpenAPI-style ``nullable: true`` extension and strict
|
||||||
schema-to-grammar converters may reject unknown keywords.
|
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:
|
if not schema:
|
||||||
return {"type": "object", "properties": {}}
|
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)
|
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
|
||||||
if not isinstance(normalized, dict):
|
if not isinstance(normalized, dict):
|
||||||
return {"type": "object", "properties": {}}
|
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):
|
if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
|
||||||
normalized = {**normalized, "properties": {}}
|
normalized = {**normalized, "properties": {}}
|
||||||
return normalized
|
return normalized
|
||||||
@@ -1915,9 +1944,15 @@ def build_anthropic_kwargs(
|
|||||||
|
|
||||||
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
|
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
|
||||||
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
|
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
|
||||||
# output speed. Only for native Anthropic endpoints — third-party
|
# output speed. Per Anthropic docs, fast mode is only supported on
|
||||||
# providers would reject the unknown beta header and speed parameter.
|
# Opus 4.6 — Opus 4.7 and other models 400 on the speed parameter.
|
||||||
if fast_mode and not _is_third_party_anthropic_endpoint(base_url):
|
# 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"
|
kwargs.setdefault("extra_body", {})["speed"] = "fast"
|
||||||
# Build extra_headers with ALL applicable betas (the per-request
|
# Build extra_headers with ALL applicable betas (the per-request
|
||||||
# extra_headers override the client-level anthropic-beta header).
|
# 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
|
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:
|
try:
|
||||||
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -1539,10 +1539,10 @@ def _try_anthropic() -> Tuple[Optional[Any], Optional[str]]:
|
|||||||
if pool_present:
|
if pool_present:
|
||||||
if entry is None:
|
if entry is None:
|
||||||
return None, None
|
return None, None
|
||||||
token = _pool_runtime_api_key(entry)
|
token = explicit_api_key or _pool_runtime_api_key(entry)
|
||||||
else:
|
else:
|
||||||
entry = None
|
entry = None
|
||||||
token = resolve_anthropic_token()
|
token = explicit_api_key or resolve_anthropic_token()
|
||||||
if not token:
|
if not token:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@@ -2336,7 +2336,7 @@ def resolve_provider_client(
|
|||||||
|
|
||||||
if pconfig.auth_type == "api_key":
|
if pconfig.auth_type == "api_key":
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
client, default_model = _try_anthropic()
|
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
|
||||||
if client is None:
|
if client is None:
|
||||||
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
||||||
return None, None
|
return None, None
|
||||||
@@ -2648,8 +2648,11 @@ def resolve_vision_provider_client(
|
|||||||
return resolved_provider, sync_client, final_model
|
return resolved_provider, sync_client, final_model
|
||||||
|
|
||||||
if resolved_base_url:
|
if resolved_base_url:
|
||||||
|
provider_for_base_override = (
|
||||||
|
requested if requested and requested not in ("", "auto") else "custom"
|
||||||
|
)
|
||||||
client, final_model = resolve_provider_client(
|
client, final_model = resolve_provider_client(
|
||||||
"custom",
|
provider_for_base_override,
|
||||||
model=resolved_model,
|
model=resolved_model,
|
||||||
async_mode=async_mode,
|
async_mode=async_mode,
|
||||||
explicit_base_url=resolved_base_url,
|
explicit_base_url=resolved_base_url,
|
||||||
@@ -2657,8 +2660,8 @@ def resolve_vision_provider_client(
|
|||||||
api_mode=resolved_api_mode,
|
api_mode=resolved_api_mode,
|
||||||
)
|
)
|
||||||
if client is None:
|
if client is None:
|
||||||
return "custom", None, None
|
return provider_for_base_override, None, None
|
||||||
return "custom", client, final_model
|
return provider_for_base_override, client, final_model
|
||||||
|
|
||||||
if requested == "auto":
|
if requested == "auto":
|
||||||
# Vision auto-detection order:
|
# Vision auto-detection order:
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ class ContextCompressor(ContextEngine):
|
|||||||
self._last_aux_model_failure_model = None
|
self._last_aux_model_failure_model = None
|
||||||
self._last_compression_savings_pct = 100.0
|
self._last_compression_savings_pct = 100.0
|
||||||
self._ineffective_compression_count = 0
|
self._ineffective_compression_count = 0
|
||||||
|
self._summary_failure_cooldown_until = 0.0 # transient errors must not block a fresh session
|
||||||
|
|
||||||
def update_model(
|
def update_model(
|
||||||
self,
|
self,
|
||||||
@@ -553,7 +554,16 @@ class ContextCompressor(ContextEngine):
|
|||||||
break
|
break
|
||||||
accumulated += msg_tokens
|
accumulated += msg_tokens
|
||||||
boundary = i
|
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:
|
else:
|
||||||
prune_boundary = len(result) - protect_tail_count
|
prune_boundary = len(result) - protect_tail_count
|
||||||
|
|
||||||
@@ -569,6 +579,8 @@ class ContextCompressor(ContextEngine):
|
|||||||
# Skip multimodal content (list of content blocks)
|
# Skip multimodal content (list of content blocks)
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
continue
|
continue
|
||||||
|
if not isinstance(content, str):
|
||||||
|
continue
|
||||||
if len(content) < 200:
|
if len(content) < 200:
|
||||||
continue
|
continue
|
||||||
h = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:12]
|
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)
|
# Skip multimodal content (list of content blocks)
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
continue
|
continue
|
||||||
|
if not isinstance(content, str):
|
||||||
|
continue
|
||||||
if not content or content == _PRUNED_TOOL_PLACEHOLDER:
|
if not content or content == _PRUNED_TOOL_PLACEHOLDER:
|
||||||
continue
|
continue
|
||||||
# Skip already-deduplicated or previously-summarized results
|
# 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 "does not exist" in _err_str
|
||||||
or "no available channel" in _err_str
|
or "no available channel" in _err_str
|
||||||
)
|
)
|
||||||
|
_is_timeout = (
|
||||||
|
_status in (408, 429, 502, 504)
|
||||||
|
or "timeout" in _err_str
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
_is_model_not_found
|
(_is_model_not_found or _is_timeout)
|
||||||
and self.summary_model
|
and self.summary_model
|
||||||
and self.summary_model != self.model
|
and self.summary_model != self.model
|
||||||
and not getattr(self, "_summary_model_fallen_back", False)
|
and not getattr(self, "_summary_model_fallen_back", False)
|
||||||
):
|
):
|
||||||
self._summary_model_fallen_back = True
|
self._summary_model_fallen_back = True
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Summary model '%s' not available (%s). "
|
"Summary model '%s' unavailable (%s). "
|
||||||
"Falling back to main model '%s' for compression.",
|
"Falling back to main model '%s' for compression.",
|
||||||
self.summary_model, e, self.model,
|
self.summary_model, e, self.model,
|
||||||
)
|
)
|
||||||
|
|||||||
+110
-35
@@ -24,11 +24,12 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
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 hermes_constants import get_hermes_home
|
||||||
from tools import skill_usage
|
from tools import skill_usage
|
||||||
@@ -36,6 +37,22 @@ from tools import skill_usage
|
|||||||
logger = logging.getLogger(__name__)
|
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_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||||
DEFAULT_MIN_IDLE_HOURS = 2
|
DEFAULT_MIN_IDLE_HOURS = 2
|
||||||
DEFAULT_STALE_AFTER_DAYS = 30
|
DEFAULT_STALE_AFTER_DAYS = 30
|
||||||
@@ -453,6 +470,24 @@ def _reports_root() -> Path:
|
|||||||
return root
|
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(
|
def _classify_removed_skills(
|
||||||
removed: List[str],
|
removed: List[str],
|
||||||
added: List[str],
|
added: List[str],
|
||||||
@@ -531,15 +566,29 @@ def _classify_removed_skills(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Look for the removed skill's name in file_path / content / raw.
|
# 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"):
|
for key in ("file_path", "file_content", "content", "new_string", "_raw"):
|
||||||
v = args.get(key)
|
v = args.get(key)
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
haystacks.append(v)
|
haystacks.append((key, v))
|
||||||
hit = False
|
hit = False
|
||||||
for hay in haystacks:
|
for key, hay in haystacks:
|
||||||
for needle in needles:
|
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
|
hit = True
|
||||||
evidence = (
|
evidence = (
|
||||||
f"skill_manage action={args.get('action', '?')} "
|
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]:
|
def _resolve_review_model(cfg: Dict[str, Any]) -> tuple[str, str]:
|
||||||
"""Pick (provider, model) for the curator review fork.
|
"""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
|
2. Legacy ``curator.auxiliary.{provider,model}`` when both are set
|
||||||
3. Main ``model.{provider,default/model}`` pair
|
3. Main ``model.{provider,default/model}`` pair
|
||||||
"""
|
"""
|
||||||
_main = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {}
|
b = _resolve_review_runtime(cfg)
|
||||||
_main_provider = _main.get("provider") or "auto"
|
return b.provider, b.model
|
||||||
_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
|
|
||||||
|
|
||||||
|
|
||||||
def _run_llm_review(prompt: str) -> Dict[str, Any]:
|
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
|
# arguments hits an auto-resolution path that fails for OAuth-only
|
||||||
# providers and for pool-backed credentials.
|
# 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
|
# (canonical aux-task slot, wired through `hermes model` → auxiliary
|
||||||
# picker and the dashboard Models tab), with a legacy fallback to
|
# 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
|
_api_key = None
|
||||||
_base_url = None
|
_base_url = None
|
||||||
_api_mode = 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.config import load_config
|
||||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||||
_cfg = load_config()
|
_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(
|
_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")
|
_api_key = _rp.get("api_key")
|
||||||
_base_url = _rp.get("base_url")
|
_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)
|
is_disconnect = any(p in error_msg for p in _SERVER_DISCONNECT_PATTERNS)
|
||||||
if is_disconnect and not status_code:
|
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:
|
if is_large:
|
||||||
return _result(
|
return _result(
|
||||||
FailoverReason.context_overflow,
|
FailoverReason.context_overflow,
|
||||||
@@ -766,7 +771,12 @@ def _classify_400(
|
|||||||
if not err_body_msg:
|
if not err_body_msg:
|
||||||
err_body_msg = str(body.get("message") or "").strip().lower()
|
err_body_msg = str(body.get("message") or "").strip().lower()
|
||||||
is_generic = len(err_body_msg) < 30 or err_body_msg in ("error", "")
|
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:
|
if is_generic and is_large:
|
||||||
return result_fn(
|
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 "")
|
finish_reason_raw = str(cand.get("finishReason") or "")
|
||||||
if finish_reason_raw:
|
if finish_reason_raw:
|
||||||
mapped = "tool_calls" if tool_call_indices else _map_gemini_finish_reason(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
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -489,16 +489,29 @@ def save_credentials(creds: GoogleCredentials) -> Path:
|
|||||||
"""Atomically write creds to disk with 0o600 permissions."""
|
"""Atomically write creds to disk with 0o600 permissions."""
|
||||||
path = _credentials_path()
|
path = _credentials_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
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"
|
payload = json.dumps(creds.to_dict(), indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
with _credentials_lock():
|
with _credentials_lock():
|
||||||
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||||
try:
|
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.write(payload)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
os.fsync(fh.fileno())
|
||||||
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
||||||
atomic_replace(tmp_path, path)
|
atomic_replace(tmp_path, path)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ SKILLS_GUIDANCE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
KANBAN_GUIDANCE = (
|
KANBAN_GUIDANCE = (
|
||||||
"# You are a Kanban worker\n"
|
"# Kanban task execution protocol\n"
|
||||||
"You were spawned by the Hermes Kanban dispatcher to execute ONE task from "
|
"You have been assigned ONE task from "
|
||||||
"the shared board at `~/.hermes/kanban.db`. Your task id is in "
|
"the shared board at `~/.hermes/kanban.db`. Your task id is in "
|
||||||
"`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
|
"`$HERMES_KANBAN_TASK`; your workspace is `$HERMES_KANBAN_WORKSPACE`. "
|
||||||
"The `kanban_*` tools in your schema are your primary coordination surface — "
|
"The `kanban_*` tools in your schema are your primary coordination surface — "
|
||||||
|
|||||||
+17
-11
@@ -305,13 +305,18 @@ def _redact_form_body(text: str) -> str:
|
|||||||
return _redact_query_string(text.strip())
|
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.
|
"""Apply all redaction patterns to a block of text.
|
||||||
|
|
||||||
Safe to call on any string -- non-matching text passes through unchanged.
|
Safe to call on any string -- non-matching text passes through unchanged.
|
||||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||||
Set force=True for safety boundaries that must never return raw secrets
|
Set force=True for safety boundaries that must never return raw secrets
|
||||||
regardless of the user's global logging redaction preference.
|
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:
|
if text is None:
|
||||||
return None
|
return None
|
||||||
@@ -325,17 +330,18 @@ def redact_sensitive_text(text: str, *, force: bool = False) -> str:
|
|||||||
# Known prefixes (sk-, ghp_, etc.)
|
# Known prefixes (sk-, ghp_, etc.)
|
||||||
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
|
text = _PREFIX_RE.sub(lambda m: _mask_token(m.group(1)), text)
|
||||||
|
|
||||||
# ENV assignments: OPENAI_API_KEY=sk-abc...
|
# ENV assignments: OPENAI_API_KEY=*** (skip for code files — false positives)
|
||||||
def _redact_env(m):
|
if not code_file:
|
||||||
name, quote, value = m.group(1), m.group(2), m.group(3)
|
def _redact_env(m):
|
||||||
return f"{name}={quote}{_mask_token(value)}{quote}"
|
name, quote, value = m.group(1), m.group(2), m.group(3)
|
||||||
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
return f"{name}={quote}{_mask_token(value)}{quote}"
|
||||||
|
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
|
||||||
|
|
||||||
# JSON fields: "apiKey": "value"
|
# JSON fields: "apiKey": "***" (skip for code files — false positives)
|
||||||
def _redact_json(m):
|
def _redact_json(m):
|
||||||
key, value = m.group(1), m.group(2)
|
key, value = m.group(1), m.group(2)
|
||||||
return f'{key}: "{_mask_token(value)}"'
|
return f'{key}: "{_mask_token(value)}"'
|
||||||
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
text = _JSON_FIELD_RE.sub(_redact_json, text)
|
||||||
|
|
||||||
# Authorization headers
|
# Authorization headers
|
||||||
text = _AUTH_HEADER_RE.sub(
|
text = _AUTH_HEADER_RE.sub(
|
||||||
|
|||||||
@@ -143,7 +143,18 @@ class ResponsesApiTransport(ProviderTransport):
|
|||||||
kwargs["max_output_tokens"] = max_tokens
|
kwargs["max_output_tokens"] = max_tokens
|
||||||
|
|
||||||
if is_xai_responses and session_id:
|
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
|
return kwargs
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ function RootRedirect() {
|
|||||||
return <Navigate to="/sessions" replace />;
|
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 = {
|
const CHAT_NAV_ITEM: NavItem = {
|
||||||
path: "/chat",
|
path: "/chat",
|
||||||
labelKey: "chat",
|
labelKey: "chat",
|
||||||
@@ -582,7 +590,9 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate to="/sessions" replace />}
|
element={
|
||||||
|
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</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 { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||||
import { Typography } from "@/components/NouiTypography";
|
import { Typography } from "@/components/NouiTypography";
|
||||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||||
|
import type { DashboardTheme } from "@/themes";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
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.
|
* Compact theme picker mounted next to the language switcher in the header.
|
||||||
* Each dropdown row shows a 3-stop swatch (background / midground / warm
|
* Each dropdown row shows a 3-stop swatch (background / midground / warm
|
||||||
* glow) so users can preview the palette before committing. User-defined
|
* glow) so users can preview the palette before committing. User-defined
|
||||||
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
|
* themes from `~/.hermes/dashboard-themes/*.yaml` use their API-provided
|
||||||
* `BUILTIN_THEMES` render without swatches and apply the default palette.
|
* 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
|
* 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
|
* `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) => {
|
{availableThemes.map((th) => {
|
||||||
const isActive = th.name === themeName;
|
const isActive = th.name === themeName;
|
||||||
const preset = BUILTIN_THEMES[th.name];
|
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -109,8 +110,8 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||||||
}}
|
}}
|
||||||
className="gap-3"
|
className="gap-3"
|
||||||
>
|
>
|
||||||
{preset ? (
|
{paletteTheme ? (
|
||||||
<ThemeSwatch theme={preset.name} />
|
<ThemeSwatch theme={paletteTheme} />
|
||||||
) : (
|
) : (
|
||||||
<PlaceholderSwatch />
|
<PlaceholderSwatch />
|
||||||
)}
|
)}
|
||||||
@@ -144,10 +145,8 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeSwatch({ theme }: { theme: string }) {
|
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||||
const preset = BUILTIN_THEMES[theme];
|
const { background, midground, warmGlow } = theme.palette;
|
||||||
if (!preset) return <PlaceholderSwatch />;
|
|
||||||
const { background, midground, warmGlow } = preset.palette;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
FileQuestion,
|
FileQuestion,
|
||||||
Filter,
|
Filter,
|
||||||
|
Cloud,
|
||||||
|
Sparkles,
|
||||||
|
LayoutDashboard,
|
||||||
|
BookOpen,
|
||||||
|
Route,
|
||||||
|
History,
|
||||||
|
Shield,
|
||||||
|
FileOutput,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||||
@@ -66,6 +75,15 @@ const CATEGORY_ICONS: Record<
|
|||||||
logging: ClipboardList,
|
logging: ClipboardList,
|
||||||
discord: MessageCircle,
|
discord: MessageCircle,
|
||||||
auxiliary: Wrench,
|
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({
|
function CategoryIcon({
|
||||||
|
|||||||
@@ -311,9 +311,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
/** All selectable themes (shown in the picker). Starts with just the
|
/** All selectable themes (shown in the picker). Starts with just the
|
||||||
* built-ins; the API call below merges in user themes. */
|
* built-ins; the API call below merges in user themes. */
|
||||||
const [availableThemes, setAvailableThemes] = useState<
|
const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() =>
|
||||||
Array<{ description: string; label: string; name: string }>
|
|
||||||
>(() =>
|
|
||||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
@@ -360,6 +358,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
name: t.name,
|
name: t.name,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
|
definition: t.definition,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
// Index any definitions the server shipped (user themes).
|
// Index any definitions the server shipped (user themes).
|
||||||
@@ -430,8 +429,15 @@ const ThemeContext = createContext<ThemeContextValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
availableThemes: Array<{ description: string; label: string; name: string }>;
|
availableThemes: ThemeSummary[];
|
||||||
setTheme: (name: string) => void;
|
setTheme: (name: string) => void;
|
||||||
theme: DashboardTheme;
|
theme: DashboardTheme;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThemeSummary {
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
definition?: DashboardTheme;
|
||||||
|
}
|
||||||
|
|||||||
@@ -459,32 +459,19 @@ def load_cli_config() -> Dict[str, Any]:
|
|||||||
if "backend" in terminal_config:
|
if "backend" in terminal_config:
|
||||||
terminal_config["env_type"] = terminal_config["backend"]
|
terminal_config["env_type"] = terminal_config["backend"]
|
||||||
|
|
||||||
# Handle special cwd values: "." or "auto" means use current working directory.
|
# CWD resolution for CLI/TUI. The gateway has its own config bridge in
|
||||||
# Only resolve to the host's CWD for the local backend where the host
|
# gateway/run.py but may lazily import cli.py (triggering this code).
|
||||||
# filesystem is directly accessible. For ALL remote/container backends
|
# Local backend: always os.getcwd(). Use `cd /dir && hermes` to control it.
|
||||||
# (ssh, docker, modal, singularity), the host path doesn't exist on the
|
# Non-local with placeholder: pop so terminal_tool uses its per-backend default.
|
||||||
# target -- remove the key so terminal_tool.py uses its per-backend default.
|
# Non-local with explicit path: keep as-is.
|
||||||
#
|
|
||||||
# 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_PLACEHOLDERS = (".", "auto", "cwd")
|
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||||
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
effective_backend = terminal_config.get("env_type", "local")
|
||||||
_existing_cwd = os.environ.get("TERMINAL_CWD", "")
|
|
||||||
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
|
if effective_backend == "local":
|
||||||
# Gateway (or earlier startup) already resolved a real path — keep it
|
terminal_config["cwd"] = os.getcwd()
|
||||||
terminal_config["cwd"] = _existing_cwd
|
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||||
defaults["terminal"]["cwd"] = _existing_cwd
|
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||||
else:
|
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"]
|
|
||||||
else:
|
|
||||||
# Remove so TERMINAL_CWD stays unset → tool picks backend default
|
|
||||||
terminal_config.pop("cwd", None)
|
|
||||||
|
|
||||||
env_mappings = {
|
env_mappings = {
|
||||||
"env_type": "TERMINAL_ENV",
|
"env_type": "TERMINAL_ENV",
|
||||||
@@ -517,13 +504,18 @@ def load_cli_config() -> Dict[str, Any]:
|
|||||||
"sudo_password": "SUDO_PASSWORD",
|
"sudo_password": "SUDO_PASSWORD",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Apply config values to env vars so terminal_tool picks them up.
|
# Bridge config → env vars for terminal_tool. TERMINAL_CWD is force-exported
|
||||||
# If the config file explicitly has a [terminal] section, those values are
|
# UNLESS we're inside a gateway process (detected by _HERMES_GATEWAY marker)
|
||||||
# authoritative and override any .env settings. When using defaults only
|
# where it was already set correctly by gateway/run.py's config bridge.
|
||||||
# (no config file or no terminal section), don't overwrite env vars that
|
_is_gateway = os.environ.get("_HERMES_GATEWAY") == "1"
|
||||||
# were already set by .env -- the user's .env is the fallback source.
|
|
||||||
for config_key, env_var in env_mappings.items():
|
for config_key, env_var in env_mappings.items():
|
||||||
if config_key in terminal_config:
|
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:
|
if _file_has_terminal_config or env_var not in os.environ:
|
||||||
val = terminal_config[config_key]
|
val = terminal_config[config_key]
|
||||||
if isinstance(val, list):
|
if isinstance(val, list):
|
||||||
@@ -1234,6 +1226,28 @@ def _strip_markdown_syntax(text: str) -> str:
|
|||||||
return plain.strip("\n")
|
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"):
|
def _render_final_assistant_content(text: str, mode: str = "render"):
|
||||||
"""Render final assistant content as markdown, stripped text, or raw text."""
|
"""Render final assistant content as markdown, stripped text, or raw text."""
|
||||||
from rich.markdown import Markdown
|
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 "")
|
return _rich_text_from_ansi(text or "")
|
||||||
|
|
||||||
plain = _rich_text_from_ansi(text or "").plain
|
plain = _rich_text_from_ansi(text or "").plain
|
||||||
|
plain = _preserve_windows_dot_segments_for_markdown(plain)
|
||||||
return 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 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())
|
or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha())
|
||||||
)
|
)
|
||||||
if not starts_like_path:
|
if not starts_like_path:
|
||||||
@@ -4936,7 +4955,7 @@ class HermesCLI:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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."""
|
"""Start a fresh session with a new session ID and cleared agent state."""
|
||||||
if self.agent and self.conversation_history:
|
if self.agent and self.conversation_history:
|
||||||
# Trigger memory extraction on the old session before session_id rotates.
|
# Trigger memory extraction on the old session before session_id rotates.
|
||||||
@@ -4991,6 +5010,28 @@ class HermesCLI:
|
|||||||
self.agent._session_db_created = True
|
self.agent._session_db_created = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Notify memory providers that session_id rotated to a fresh
|
||||||
# conversation. reset=True signals providers to flush accumulated
|
# conversation. reset=True signals providers to flush accumulated
|
||||||
# per-session state (_session_turns, _turn_counter, _document_id).
|
# per-session state (_session_turns, _turn_counter, _document_id).
|
||||||
@@ -5010,7 +5051,10 @@ class HermesCLI:
|
|||||||
self._notify_session_boundary("on_session_reset")
|
self._notify_session_boundary("on_session_reset")
|
||||||
|
|
||||||
if not silent:
|
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:
|
def _handle_resume_command(self, cmd_original: str) -> None:
|
||||||
"""Handle /resume <session_id_or_title> — switch to a previous session mid-conversation."""
|
"""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)
|
_cmd_def = _resolve_cmd(_base_word)
|
||||||
canonical = _cmd_def.name if _cmd_def else _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
|
return False
|
||||||
elif canonical == "help":
|
elif canonical == "help":
|
||||||
self.show_help()
|
self.show_help()
|
||||||
@@ -6422,7 +6466,9 @@ class HermesCLI:
|
|||||||
else:
|
else:
|
||||||
_cprint(" Session database not available.")
|
_cprint(" Session database not available.")
|
||||||
elif canonical == "new":
|
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":
|
elif canonical == "resume":
|
||||||
self._handle_resume_command(cmd_original)
|
self._handle_resume_command(cmd_original)
|
||||||
elif canonical == "model":
|
elif canonical == "model":
|
||||||
@@ -8383,6 +8429,17 @@ class HermesCLI:
|
|||||||
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
|
_cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}")
|
||||||
threading.Thread(target=_restart_recording, daemon=True).start()
|
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):
|
def _voice_speak_response(self, text: str):
|
||||||
"""Speak the agent's response aloud using TTS (runs in background thread)."""
|
"""Speak the agent's response aloud using TTS (runs in background thread)."""
|
||||||
if not self._voice_tts:
|
if not self._voice_tts:
|
||||||
@@ -9543,11 +9600,7 @@ class HermesCLI:
|
|||||||
# Speak response aloud if voice TTS is enabled
|
# Speak response aloud if voice TTS is enabled
|
||||||
# Skip batch TTS when streaming TTS already handled it
|
# Skip batch TTS when streaming TTS already handled it
|
||||||
if self._voice_tts and response and not use_streaming_tts:
|
if self._voice_tts and response and not use_streaming_tts:
|
||||||
threading.Thread(
|
self._voice_speak_response_async(response)
|
||||||
target=self._voice_speak_response,
|
|
||||||
args=(response,),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
|
|
||||||
# Re-queue the interrupt message (and any that arrived while we were
|
# Re-queue the interrupt message (and any that arrived while we were
|
||||||
|
|||||||
+19
-2
@@ -797,19 +797,36 @@ def get_due_jobs() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
next_run = job.get("next_run_at")
|
next_run = job.get("next_run_at")
|
||||||
if not next_run:
|
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(
|
recovered_next = _recoverable_oneshot_run_at(
|
||||||
job.get("schedule", {}),
|
schedule,
|
||||||
now,
|
now,
|
||||||
last_run_at=job.get("last_run_at"),
|
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:
|
if not recovered_next:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
job["next_run_at"] = recovered_next
|
job["next_run_at"] = recovered_next
|
||||||
next_run = recovered_next
|
next_run = recovered_next
|
||||||
logger.info(
|
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"]),
|
job.get("name", job["id"]),
|
||||||
|
recovery_kind,
|
||||||
recovered_next,
|
recovered_next,
|
||||||
)
|
)
|
||||||
for rj in raw_jobs:
|
for rj in raw_jobs:
|
||||||
|
|||||||
+19
-6
@@ -417,7 +417,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
|||||||
thread_id = target.get("thread_id")
|
thread_id = target.get("thread_id")
|
||||||
|
|
||||||
# Diagnostic: log thread_id for topic-aware delivery debugging
|
# 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")
|
origin_thread = origin.get("thread_id")
|
||||||
if origin_thread and not thread_id:
|
if origin_thread and not thread_id:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -706,10 +706,8 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
|||||||
f"{prompt}"
|
f"{prompt}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
prompt = (
|
# Script produced no output — nothing to report, skip AI call.
|
||||||
"[Script ran successfully but produced no output.]\n\n"
|
return None
|
||||||
f"{prompt}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
prompt = (
|
prompt = (
|
||||||
"## Script Error\n"
|
"## Script Error\n"
|
||||||
@@ -782,6 +780,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
|||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
from tools.skills_tool import skill_view
|
from tools.skills_tool import skill_view
|
||||||
|
from tools.skill_usage import bump_use
|
||||||
|
|
||||||
parts = []
|
parts = []
|
||||||
skipped: list[str] = []
|
skipped: list[str] = []
|
||||||
@@ -793,6 +792,12 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
|||||||
skipped.append(skill_name)
|
skipped.append(skill_name)
|
||||||
continue
|
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()
|
content = str(loaded.get("content") or "").strip()
|
||||||
if parts:
|
if parts:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
@@ -862,6 +867,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||||||
return True, silent_doc, SILENT_MARKER, None
|
return True, silent_doc, SILENT_MARKER, None
|
||||||
|
|
||||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
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)
|
origin = _resolve_origin(job)
|
||||||
_cron_session_id = f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
|
_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
|
from hermes_cli.auth import AuthError
|
||||||
try:
|
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 = {
|
runtime_kwargs = {
|
||||||
"requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"),
|
"requested": job.get("provider"),
|
||||||
}
|
}
|
||||||
if job.get("base_url"):
|
if job.get("base_url"):
|
||||||
runtime_kwargs["explicit_base_url"] = 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"
|
python3 "$INSTALL_DIR/tools/skills_sync.py"
|
||||||
fi
|
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.
|
# Final exec: two supported invocation patterns.
|
||||||
#
|
#
|
||||||
# docker run <image> -> exec `hermes` with no args (legacy default)
|
# docker run <image> -> exec `hermes` with no args (legacy default)
|
||||||
|
|||||||
+16
-2
@@ -846,11 +846,25 @@ def load_gateway_config() -> GatewayConfig:
|
|||||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
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 settings → env vars (env vars take precedence)
|
||||||
telegram_cfg = yaml_cfg.get("telegram", {})
|
telegram_cfg = yaml_cfg.get("telegram", {})
|
||||||
if isinstance(telegram_cfg, dict):
|
if isinstance(telegram_cfg, dict):
|
||||||
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
|
# Prefer telegram.require_mention; fall back to the top-level shorthand.
|
||||||
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
|
_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"):
|
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
|
||||||
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
|
||||||
frc = telegram_cfg.get("free_response_chats")
|
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
|
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(
|
def _normalize_chat_content(
|
||||||
content: Any, *, _max_depth: int = 10, _depth: int = 0,
|
content: Any, *, _max_depth: int = 10, _depth: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -573,7 +581,10 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
super().__init__(config, Platform.API_SERVER)
|
super().__init__(config, Platform.API_SERVER)
|
||||||
extra = config.extra or {}
|
extra = config.extra or {}
|
||||||
self._host: str = extra.get("host", os.getenv("API_SERVER_HOST", DEFAULT_HOST))
|
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._api_key: str = extra.get("key", os.getenv("API_SERVER_KEY", ""))
|
||||||
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
self._cors_origins: tuple[str, ...] = self._parse_cors_origins(
|
||||||
extra.get("cors_origins", os.getenv("API_SERVER_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.
|
gateway platforms), falling back to the hermes-api-server default.
|
||||||
"""
|
"""
|
||||||
from run_agent import AIAgent
|
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
|
from hermes_cli.tools_config import _get_platform_tools
|
||||||
|
|
||||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||||
|
reasoning_config = GatewayRunner._load_reasoning_config()
|
||||||
model = _resolve_gateway_model()
|
model = _resolve_gateway_model()
|
||||||
|
|
||||||
user_config = _load_gateway_config()
|
user_config = _load_gateway_config()
|
||||||
@@ -740,7 +752,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
# Load fallback provider chain so the API server platform has the
|
# Load fallback provider chain so the API server platform has the
|
||||||
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
|
# same fallback behaviour as Telegram/Discord/Slack (fixes #4954).
|
||||||
from gateway.run import GatewayRunner
|
|
||||||
fallback_model = GatewayRunner._load_fallback_model()
|
fallback_model = GatewayRunner._load_fallback_model()
|
||||||
|
|
||||||
agent = AIAgent(
|
agent = AIAgent(
|
||||||
@@ -759,6 +770,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
tool_complete_callback=tool_complete_callback,
|
tool_complete_callback=tool_complete_callback,
|
||||||
session_db=self._ensure_session_db(),
|
session_db=self._ensure_session_db(),
|
||||||
fallback_model=fallback_model,
|
fallback_model=fallback_model,
|
||||||
|
reasoning_config=reasoning_config,
|
||||||
)
|
)
|
||||||
return agent
|
return agent
|
||||||
|
|
||||||
@@ -2566,21 +2578,39 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||||||
return r, u
|
return r, u
|
||||||
|
|
||||||
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
|
result, usage = await asyncio.get_running_loop().run_in_executor(None, _run_sync)
|
||||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
# Check for structured failure (non-retryable client errors like
|
||||||
q.put_nowait({
|
# 401/400 return failed=True instead of raising, so the except
|
||||||
"event": "run.completed",
|
# block below never fires — issue #15561).
|
||||||
"run_id": run_id,
|
if isinstance(result, dict) and result.get("failed"):
|
||||||
"timestamp": time.time(),
|
error_msg = result.get("error") or "agent run failed"
|
||||||
"output": final_response,
|
q.put_nowait({
|
||||||
"usage": usage,
|
"event": "run.failed",
|
||||||
})
|
"run_id": run_id,
|
||||||
self._set_run_status(
|
"timestamp": time.time(),
|
||||||
run_id,
|
"error": error_msg,
|
||||||
"completed",
|
})
|
||||||
output=final_response,
|
self._set_run_status(
|
||||||
usage=usage,
|
run_id,
|
||||||
last_event="run.completed",
|
"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:
|
except asyncio.CancelledError:
|
||||||
self._set_run_status(
|
self._set_run_status(
|
||||||
run_id,
|
run_id,
|
||||||
|
|||||||
@@ -2506,7 +2506,13 @@ class BasePlatformAdapter(ABC):
|
|||||||
_r = await self._send_with_retry(
|
_r = await self._send_with_retry(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=_text,
|
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,
|
metadata=thread_meta,
|
||||||
)
|
)
|
||||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||||
@@ -2606,7 +2612,13 @@ class BasePlatformAdapter(ABC):
|
|||||||
_r = await self._send_with_retry(
|
_r = await self._send_with_retry(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=_text,
|
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,
|
metadata=_thread_meta,
|
||||||
)
|
)
|
||||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||||
@@ -2810,10 +2822,15 @@ class BasePlatformAdapter(ABC):
|
|||||||
# Send the text portion
|
# Send the text portion
|
||||||
if text_content:
|
if text_content:
|
||||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
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(
|
result = await self._send_with_retry(
|
||||||
chat_id=event.source.chat_id,
|
chat_id=event.source.chat_id,
|
||||||
content=text_content,
|
content=text_content,
|
||||||
reply_to=event.message_id,
|
reply_to=_reply_anchor,
|
||||||
metadata=_thread_metadata,
|
metadata=_thread_metadata,
|
||||||
)
|
)
|
||||||
_record_delivery(result)
|
_record_delivery(result)
|
||||||
|
|||||||
@@ -720,11 +720,22 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
# If humans are mentioned but we're not → not for us
|
# If humans are mentioned but we're not → not for us
|
||||||
# (preserves old DISCORD_IGNORE_NO_MENTION=true behavior)
|
# (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(
|
_ignore_no_mention = os.getenv(
|
||||||
"DISCORD_IGNORE_NO_MENTION", "true"
|
"DISCORD_IGNORE_NO_MENTION", "true"
|
||||||
).lower() in ("true", "1", "yes")
|
).lower() in ("true", "1", "yes")
|
||||||
if _ignore_no_mention and not _self_mentioned and not _other_bots_mentioned:
|
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)
|
await self._handle_message(message)
|
||||||
|
|
||||||
@@ -3797,7 +3808,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||||||
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
if not is_thread and not isinstance(message.channel, discord.DMChannel):
|
||||||
no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
|
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()}
|
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")
|
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
|
||||||
is_reply_message = getattr(message, "type", None) == discord.MessageType.reply
|
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:
|
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:
|
if hint:
|
||||||
text = f"{hint}\n\n{text}" if text else 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 = (
|
reply_to_message_id = (
|
||||||
getattr(message, "parent_id", None)
|
getattr(message, "parent_id", None)
|
||||||
or getattr(message, "upper_message_id", None)
|
or getattr(message, "upper_message_id", None)
|
||||||
|
or getattr(message, "root_id", None)
|
||||||
or None
|
or None
|
||||||
)
|
)
|
||||||
reply_to_text = await self._fetch_message_text(reply_to_message_id) if reply_to_message_id else 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),
|
chat_type=self._resolve_source_chat_type(chat_info=chat_info, event_chat_type=chat_type),
|
||||||
user_id=sender_profile["user_id"],
|
user_id=sender_profile["user_id"],
|
||||||
user_name=sender_profile["user_name"],
|
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"],
|
user_id_alt=sender_profile["user_id_alt"],
|
||||||
is_bot=is_bot,
|
is_bot=is_bot,
|
||||||
)
|
)
|
||||||
@@ -4227,6 +4229,15 @@ class FeishuAdapter(BasePlatformAdapter):
|
|||||||
if active_reply_to and not self._response_succeeded(response):
|
if active_reply_to and not self._response_succeeded(response):
|
||||||
code = getattr(response, "code", None)
|
code = getattr(response, "code", None)
|
||||||
if code in _FEISHU_REPLY_FALLBACK_CODES:
|
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(
|
logger.warning(
|
||||||
"[Feishu] Reply to %s failed (code %s — message withdrawn/missing); "
|
"[Feishu] Reply to %s failed (code %s — message withdrawn/missing); "
|
||||||
"falling back to new message in chat %s",
|
"falling back to new message in chat %s",
|
||||||
|
|||||||
@@ -397,13 +397,24 @@ class QQAdapter(BasePlatformAdapter):
|
|||||||
await self._session.close()
|
await self._session.close()
|
||||||
self._session = None
|
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(
|
self._ws = await self._session.ws_connect(
|
||||||
gateway_url,
|
gateway_url,
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": build_user_agent(),
|
"User-Agent": build_user_agent(),
|
||||||
},
|
},
|
||||||
timeout=CONNECT_TIMEOUT_SECONDS,
|
timeout=CONNECT_TIMEOUT_SECONDS,
|
||||||
|
proxy=ws_proxy,
|
||||||
)
|
)
|
||||||
logger.info("[%s] WebSocket connected to %s", self._log_tag, gateway_url)
|
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", "")
|
group_allowed_str = os.getenv("SIGNAL_GROUP_ALLOWED_USERS", "")
|
||||||
self.group_allow_from = set(_parse_comma_list(group_allowed_str))
|
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
|
# HTTP client
|
||||||
self.client: Optional[httpx.AsyncClient] = None
|
self.client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
@@ -1430,8 +1439,28 @@ class SignalAdapter(BasePlatformAdapter):
|
|||||||
return None
|
return None
|
||||||
return (author, ts)
|
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:
|
async def on_processing_start(self, event: MessageEvent) -> None:
|
||||||
"""React with 👀 when processing begins."""
|
"""React with 👀 when processing begins."""
|
||||||
|
if not self._reactions_enabled(event):
|
||||||
|
return
|
||||||
target = self._extract_reaction_target(event)
|
target = self._extract_reaction_target(event)
|
||||||
if target:
|
if target:
|
||||||
await self.send_reaction(event.source.chat_id, "👀", *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
|
On CANCELLED we leave the 👀 in place — no terminal outcome means
|
||||||
the reaction should keep reflecting "in progress" (matches Telegram).
|
the reaction should keep reflecting "in progress" (matches Telegram).
|
||||||
"""
|
"""
|
||||||
|
if not self._reactions_enabled(event):
|
||||||
|
return
|
||||||
if outcome == ProcessingOutcome.CANCELLED:
|
if outcome == ProcessingOutcome.CANCELLED:
|
||||||
return
|
return
|
||||||
target = self._extract_reaction_target(event)
|
target = self._extract_reaction_target(event)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Shares credentials with the optional telephony skill — same env vars:
|
|||||||
|
|
||||||
Gateway-specific env vars:
|
Gateway-specific env vars:
|
||||||
- SMS_WEBHOOK_PORT (default 8080)
|
- 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_WEBHOOK_URL (public URL for Twilio signature validation — required)
|
||||||
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only)
|
- SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only)
|
||||||
- SMS_ALLOWED_USERS (comma-separated E.164 phone numbers)
|
- 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"
|
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
||||||
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
|
||||||
DEFAULT_WEBHOOK_PORT = 8080
|
DEFAULT_WEBHOOK_PORT = 8080
|
||||||
DEFAULT_WEBHOOK_HOST = "0.0.0.0"
|
DEFAULT_WEBHOOK_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
def check_sms_requirements() -> bool:
|
def check_sms_requirements() -> bool:
|
||||||
@@ -91,19 +91,23 @@ class SmsAdapter(BasePlatformAdapter):
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
if not self._from_number:
|
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
|
return False
|
||||||
|
|
||||||
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
|
insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true"
|
||||||
|
|
||||||
if not self._webhook_url and not insecure_no_sig:
|
if not self._webhook_url and not insecure_no_sig:
|
||||||
logger.error(
|
msg = (
|
||||||
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
|
"[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio "
|
||||||
"signature validation. Set it to the public URL configured in your "
|
"signature validation. Set it to the public URL configured in your "
|
||||||
"Twilio console (e.g. https://example.com/webhooks/twilio). "
|
"Twilio console (e.g. https://example.com/webhooks/twilio). "
|
||||||
"For local development without validation, set "
|
"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
|
return False
|
||||||
|
|
||||||
if insecure_no_sig and not self._webhook_url:
|
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))
|
return SendResult(success=True, message_id=str(msg.message_id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
error_str = str(e)
|
||||||
"[%s] Failed to send Telegram local image, falling back to base adapter: %s",
|
# Dimension-related errors are the expected case for valid image
|
||||||
self.name,
|
# files that Telegram just refuses as photos (screenshots, extreme
|
||||||
e,
|
# aspect ratios). Log at INFO because the document fallback is
|
||||||
exc_info=True,
|
# 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(
|
async def send_document(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class WeComAdapter(BasePlatformAdapter):
|
|||||||
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
|
"""WeCom AI Bot adapter backed by a persistent WebSocket connection."""
|
||||||
|
|
||||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH
|
||||||
|
SUPPORTS_MESSAGE_EDITING = False
|
||||||
# Threshold for detecting WeCom client-side message splits.
|
# Threshold for detecting WeCom client-side message splits.
|
||||||
# When a chunk is near the 4000-char limit, a continuation is almost certain.
|
# When a chunk is near the 4000-char limit, a continuation is almost certain.
|
||||||
_SPLIT_THRESHOLD = 3900
|
_SPLIT_THRESHOLD = 3900
|
||||||
|
|||||||
@@ -1333,6 +1333,15 @@ class WeixinAdapter(BasePlatformAdapter):
|
|||||||
if message_id and self._dedup.is_duplicate(message_id):
|
if message_id and self._dedup.is_duplicate(message_id):
|
||||||
return
|
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)
|
chat_type, effective_chat_id = _guess_chat_type(message, self._account_id)
|
||||||
if chat_type == "group":
|
if chat_type == "group":
|
||||||
if self._group_policy == "disabled":
|
if self._group_policy == "disabled":
|
||||||
@@ -1347,8 +1356,6 @@ class WeixinAdapter(BasePlatformAdapter):
|
|||||||
self._token_store.set(self._account_id, sender_id, context_token)
|
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))
|
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_paths: List[str] = []
|
||||||
media_types: List[str] = []
|
media_types: List[str] = []
|
||||||
|
|
||||||
|
|||||||
+312
-80
@@ -49,6 +49,29 @@ from hermes_cli.config import cfg_get
|
|||||||
_AGENT_CACHE_MAX_SIZE = 128
|
_AGENT_CACHE_MAX_SIZE = 128
|
||||||
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
|
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
|
||||||
_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0
|
_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.
|
# Only auto-continue interrupted gateway turns while the interruption is fresh.
|
||||||
# Stale tool-tail/resume markers can otherwise revive an unrelated old task
|
# 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.
|
# 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()
|
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()
|
_ensure_ssl_certs()
|
||||||
|
|
||||||
# Add parent directory to path
|
# Add parent directory to path
|
||||||
@@ -1161,6 +1188,10 @@ class GatewayRunner:
|
|||||||
|
|
||||||
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
||||||
self._voice_mode: Dict[str, str] = self._load_voice_modes()
|
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
|
# Track background tasks to prevent garbage collection mid-execution
|
||||||
self._background_tasks: set = set()
|
self._background_tasks: set = set()
|
||||||
@@ -3246,6 +3277,11 @@ class GatewayRunner:
|
|||||||
Runs in the gateway event loop; all SQLite work is pushed to a
|
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
|
thread via ``asyncio.to_thread`` so the loop never blocks on the
|
||||||
WAL lock. Failures in one tick don't stop subsequent ticks.
|
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
|
from gateway.config import Platform as _Platform
|
||||||
try:
|
try:
|
||||||
@@ -3278,40 +3314,54 @@ class GatewayRunner:
|
|||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
def _collect():
|
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:
|
try:
|
||||||
_kb.init_db() # idempotent; handles first-run
|
boards = _kb.list_boards(include_archived=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
|
||||||
try:
|
for board_meta in boards:
|
||||||
subs = _kb.list_notify_subs(conn)
|
slug = board_meta.get("slug") or _kb.DEFAULT_BOARD
|
||||||
deliveries: list[dict] = []
|
try:
|
||||||
for sub in subs:
|
conn = _kb.connect(board=slug)
|
||||||
cursor, events = _kb.unseen_events_for_sub(
|
except Exception:
|
||||||
conn,
|
continue
|
||||||
task_id=sub["task_id"],
|
try:
|
||||||
platform=sub["platform"],
|
try:
|
||||||
chat_id=sub["chat_id"],
|
_kb.init_db(board=slug) # idempotent; handles first-run
|
||||||
thread_id=sub.get("thread_id") or "",
|
except Exception:
|
||||||
kinds=TERMINAL_KINDS,
|
pass
|
||||||
)
|
subs = _kb.list_notify_subs(conn)
|
||||||
if not events:
|
for sub in subs:
|
||||||
continue
|
cursor, events = _kb.unseen_events_for_sub(
|
||||||
task = _kb.get_task(conn, sub["task_id"])
|
conn,
|
||||||
deliveries.append({
|
task_id=sub["task_id"],
|
||||||
"sub": sub,
|
platform=sub["platform"],
|
||||||
"cursor": cursor,
|
chat_id=sub["chat_id"],
|
||||||
"events": events,
|
thread_id=sub.get("thread_id") or "",
|
||||||
"task": task,
|
kinds=TERMINAL_KINDS,
|
||||||
})
|
)
|
||||||
return deliveries
|
if not events:
|
||||||
finally:
|
continue
|
||||||
conn.close()
|
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)
|
deliveries = await asyncio.to_thread(_collect)
|
||||||
for d in deliveries:
|
for d in deliveries:
|
||||||
sub = d["sub"]
|
sub = d["sub"]
|
||||||
task = d["task"]
|
task = d["task"]
|
||||||
|
board_slug = d.get("board")
|
||||||
platform_str = (sub["platform"] or "").lower()
|
platform_str = (sub["platform"] or "").lower()
|
||||||
try:
|
try:
|
||||||
plat = _Platform(platform_str)
|
plat = _Platform(platform_str)
|
||||||
@@ -3319,7 +3369,7 @@ class GatewayRunner:
|
|||||||
# Unknown platform string; skip and advance cursor so
|
# Unknown platform string; skip and advance cursor so
|
||||||
# we don't replay forever.
|
# we don't replay forever.
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._kanban_advance, sub, d["cursor"],
|
self._kanban_advance, sub, d["cursor"], board_slug,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
adapter = self.adapters.get(plat)
|
adapter = self.adapters.get(plat)
|
||||||
@@ -3409,14 +3459,14 @@ class GatewayRunner:
|
|||||||
"%s on %s after %d consecutive send failures",
|
"%s on %s after %d consecutive send failures",
|
||||||
sub["task_id"], platform_str, fails,
|
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)
|
sub_fail_counts.pop(sub_key, None)
|
||||||
# Don't advance cursor on send failure — retry next tick.
|
# Don't advance cursor on send failure — retry next tick.
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# All events delivered; advance cursor + maybe unsub.
|
# All events delivered; advance cursor + maybe unsub.
|
||||||
await asyncio.to_thread(
|
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
|
# Unsubscribe when the LAST delivered event is a
|
||||||
# terminal kind (the task hit a "no further updates"
|
# terminal kind (the task hit a "no further updates"
|
||||||
@@ -3428,7 +3478,7 @@ class GatewayRunner:
|
|||||||
event_terminal = last_kind in TERMINAL_EVENT_KINDS
|
event_terminal = last_kind in TERMINAL_EVENT_KINDS
|
||||||
if task_terminal or event_terminal:
|
if task_terminal or event_terminal:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._kanban_unsub, sub,
|
self._kanban_unsub, sub, board_slug,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("kanban notifier tick failed: %s", exc)
|
logger.warning("kanban notifier tick failed: %s", exc)
|
||||||
@@ -3438,10 +3488,16 @@ class GatewayRunner:
|
|||||||
return
|
return
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
def _kanban_advance(self, sub: dict, cursor: int) -> None:
|
def _kanban_advance(
|
||||||
"""Sync helper: advance a subscription's cursor. Runs in to_thread."""
|
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
|
from hermes_cli import kanban_db as _kb
|
||||||
conn = _kb.connect()
|
conn = _kb.connect(board=board)
|
||||||
try:
|
try:
|
||||||
_kb.advance_notify_cursor(
|
_kb.advance_notify_cursor(
|
||||||
conn,
|
conn,
|
||||||
@@ -3454,9 +3510,9 @@ class GatewayRunner:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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
|
from hermes_cli import kanban_db as _kb
|
||||||
conn = _kb.connect()
|
conn = _kb.connect(board=board)
|
||||||
try:
|
try:
|
||||||
_kb.remove_notify_sub(
|
_kb.remove_notify_sub(
|
||||||
conn,
|
conn,
|
||||||
@@ -3534,20 +3590,25 @@ class GatewayRunner:
|
|||||||
bad_ticks = 0
|
bad_ticks = 0
|
||||||
last_warn_at = 0
|
last_warn_at = 0
|
||||||
|
|
||||||
def _tick_once() -> "Optional[object]":
|
def _tick_once_for_board(slug: str) -> "Optional[object]":
|
||||||
"""Run one dispatch_once; return result or None on error.
|
"""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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _kb.connect()
|
conn = _kb.connect(board=slug)
|
||||||
try:
|
try:
|
||||||
_kb.init_db() # idempotent, handles first-run
|
_kb.init_db(board=slug) # idempotent, handles first-run
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _kb.dispatch_once(conn)
|
return _kb.dispatch_once(conn, board=slug)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("kanban dispatcher: tick failed")
|
logger.exception("kanban dispatcher: tick failed on board %s", slug)
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
@@ -3556,49 +3617,77 @@ class GatewayRunner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _ready_nonempty() -> bool:
|
def _tick_once() -> "list[tuple[str, Optional[object]]]":
|
||||||
"""Cheap probe: is there at least one ready+assigned+unclaimed task?"""
|
"""Run one dispatch_once per board. Returns (slug, result) pairs.
|
||||||
conn = None
|
|
||||||
|
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:
|
try:
|
||||||
conn = _kb.connect()
|
boards = _kb.list_boards(include_archived=False)
|
||||||
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
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
boards = [_kb.read_board_metadata(_kb.DEFAULT_BOARD)]
|
||||||
finally:
|
out: list[tuple[str, "Optional[object]"]] = []
|
||||||
if conn is not None:
|
for b in boards:
|
||||||
try:
|
slug = b.get("slug") or _kb.DEFAULT_BOARD
|
||||||
conn.close()
|
out.append((slug, _tick_once_for_board(slug)))
|
||||||
except Exception:
|
return out
|
||||||
pass
|
|
||||||
|
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(
|
logger.info(
|
||||||
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
"kanban dispatcher: embedded in gateway (interval=%.1fs)", interval
|
||||||
)
|
)
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
res = await asyncio.to_thread(_tick_once)
|
results = await asyncio.to_thread(_tick_once)
|
||||||
if res is not None and getattr(res, "spawned", None):
|
any_spawned = False
|
||||||
# Quiet by default — only log when something actually
|
for slug, res in (results or []):
|
||||||
# happened, so an idle gateway stays silent.
|
if res is not None and getattr(res, "spawned", None):
|
||||||
logger.info(
|
any_spawned = True
|
||||||
"kanban dispatcher: tick spawned=%d reclaimed=%d "
|
# Quiet by default — only log when something actually
|
||||||
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
|
# happened, so an idle gateway stays silent.
|
||||||
len(res.spawned),
|
logger.info(
|
||||||
res.reclaimed,
|
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
|
||||||
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
|
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
|
||||||
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
|
slug,
|
||||||
res.promoted,
|
len(res.spawned),
|
||||||
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
|
res.reclaimed,
|
||||||
)
|
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
|
||||||
# Health telemetry
|
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)
|
ready_pending = await asyncio.to_thread(_ready_nonempty)
|
||||||
spawned_any = bool(res and getattr(res, "spawned", None))
|
if ready_pending and not any_spawned:
|
||||||
if ready_pending and not spawned_any:
|
|
||||||
bad_ticks += 1
|
bad_ticks += 1
|
||||||
else:
|
else:
|
||||||
bad_ticks = 0
|
bad_ticks = 0
|
||||||
@@ -5107,6 +5196,28 @@ class GatewayRunner:
|
|||||||
_cmd_def = _resolve_cmd(command) if command else None
|
_cmd_def = _resolve_cmd(command) if command else None
|
||||||
canonical = _cmd_def.name if _cmd_def else command
|
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
|
# Fire the ``command:<canonical>`` hook for any recognized slash
|
||||||
# command — built-in OR plugin-registered. Handlers can return a
|
# command — built-in OR plugin-registered. Handlers can return a
|
||||||
# dict with ``{"decision": "deny" | "handled" | "rewrite", ...}``
|
# dict with ``{"decision": "deny" | "handled" | "rewrite", ...}``
|
||||||
@@ -5320,7 +5431,7 @@ class GatewayRunner:
|
|||||||
target_command = target.lstrip("/")
|
target_command = target.lstrip("/")
|
||||||
user_args = event.get_command_args().strip()
|
user_args = event.get_command_args().strip()
|
||||||
event.text = f"{target} {user_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
|
# Fall through to normal command dispatch below
|
||||||
else:
|
else:
|
||||||
return f"Quick command '/{command}' has no target defined."
|
return f"Quick command '/{command}' has no target defined."
|
||||||
@@ -6681,6 +6792,7 @@ class GatewayRunner:
|
|||||||
base_url = None
|
base_url = None
|
||||||
api_key = None
|
api_key = None
|
||||||
custom_provs = None
|
custom_provs = None
|
||||||
|
data = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = _load_gateway_config()
|
data = _load_gateway_config()
|
||||||
@@ -6703,6 +6815,41 @@ class GatewayRunner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Resolve runtime credentials for probing
|
||||||
try:
|
try:
|
||||||
runtime = _resolve_runtime_agent_kwargs()
|
runtime = _resolve_runtime_agent_kwargs()
|
||||||
@@ -6843,6 +6990,29 @@ class GatewayRunner:
|
|||||||
new_entry = self.session_store.get_or_create_session(source, force_new=True)
|
new_entry = self.session_store.get_or_create_session(source, force_new=True)
|
||||||
header = "✨ New session started!"
|
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)
|
# Fire plugin on_session_reset hook (new session guaranteed to exist)
|
||||||
try:
|
try:
|
||||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
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.")
|
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
async def _handle_commands_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /commands [page] - paginated list of all commands and skills."""
|
"""Handle /commands [page] - paginated list of all commands and skills."""
|
||||||
@@ -7351,7 +7524,10 @@ class GatewayRunner:
|
|||||||
lines.extend(["", " | ".join(nav_parts)])
|
lines.extend(["", " | ".join(nav_parts)])
|
||||||
if page != requested_page:
|
if page != requested_page:
|
||||||
lines.append(f"_(Requested page {requested_page} was out of range, showing page {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]:
|
async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
|
||||||
"""Handle /model command — switch model for this session.
|
"""Handle /model command — switch model for this session.
|
||||||
@@ -8261,6 +8437,47 @@ class GatewayRunner:
|
|||||||
adapter = self.adapters.get(Platform.DISCORD)
|
adapter = self.adapters.get(Platform.DISCORD)
|
||||||
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
|
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(
|
async def _handle_voice_channel_input(
|
||||||
self, guild_id: int, user_id: int, transcript: str
|
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)
|
logger.debug("Unauthorized voice input from user %d, ignoring", user_id)
|
||||||
return
|
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)
|
# Show transcript in text channel (after auth, with mention sanitization)
|
||||||
try:
|
try:
|
||||||
channel = adapter._client.get_channel(text_ch_id)
|
channel = adapter._client.get_channel(text_ch_id)
|
||||||
@@ -11311,6 +11537,12 @@ class GatewayRunner:
|
|||||||
if not session_key:
|
if not session_key:
|
||||||
return
|
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)
|
pending_approvals = getattr(self, "_pending_approvals", None)
|
||||||
if isinstance(pending_approvals, dict):
|
if isinstance(pending_approvals, dict):
|
||||||
pending_approvals.pop(session_key, None)
|
pending_approvals.pop(session_key, None)
|
||||||
|
|||||||
+2
-2
@@ -1121,7 +1121,7 @@ class SessionStore:
|
|||||||
self._save()
|
self._save()
|
||||||
return count
|
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."""
|
"""Force reset a session, creating a new session ID."""
|
||||||
db_end_session_id = None
|
db_end_session_id = None
|
||||||
db_create_kwargs = None
|
db_create_kwargs = None
|
||||||
@@ -1145,7 +1145,7 @@ class SessionStore:
|
|||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
origin=old_entry.origin,
|
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,
|
platform=old_entry.platform,
|
||||||
chat_type=old_entry.chat_type,
|
chat_type=old_entry.chat_type,
|
||||||
is_fresh_reset=True,
|
is_fresh_reset=True,
|
||||||
|
|||||||
@@ -11,5 +11,37 @@ Provides subcommands for:
|
|||||||
- hermes cron - Manage cron jobs
|
- hermes cron - Manage cron jobs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
__version__ = "0.12.0"
|
__version__ = "0.12.0"
|
||||||
__release_date__ = "2026.4.30"
|
__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()
|
||||||
|
|||||||
+263
-13
@@ -2589,6 +2589,208 @@ def _poll_for_token(
|
|||||||
# Nous Portal — token refresh, agent key minting, model discovery
|
# 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(
|
def _refresh_access_token(
|
||||||
*,
|
*,
|
||||||
client: httpx.Client,
|
client: httpx.Client,
|
||||||
@@ -2991,6 +3193,12 @@ def persist_nous_credentials(
|
|||||||
_save_provider_state(auth_store, "nous", state)
|
_save_provider_state(auth_store, "nous", state)
|
||||||
_save_auth_store(auth_store)
|
_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")
|
pool = load_pool("nous")
|
||||||
return next(
|
return next(
|
||||||
(e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE),
|
(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")),
|
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
|
||||||
access_token_fp=_token_fingerprint(state.get("access_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)
|
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||||||
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
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}")
|
print(f"Portal: {portal_base_url}")
|
||||||
|
|
||||||
with httpx.Client(timeout=httpx.Timeout(timeout_seconds),
|
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(
|
code_data = _minimax_request_user_code(
|
||||||
client, portal_base_url=portal_base_url,
|
client, portal_base_url=portal_base_url,
|
||||||
client_id=pconfig.client_id,
|
client_id=pconfig.client_id,
|
||||||
@@ -4360,7 +4574,8 @@ def _refresh_minimax_oauth_state(
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
portal_base_url = state["portal_base_url"]
|
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(
|
response = client.post(
|
||||||
f"{portal_base_url}/oauth/token",
|
f"{portal_base_url}/oauth/token",
|
||||||
data={
|
data={
|
||||||
@@ -4598,17 +4813,47 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
auth_state = _nous_device_code_login(
|
auth_state = None
|
||||||
portal_base_url=getattr(args, "portal_url", None),
|
|
||||||
inference_base_url=getattr(args, "inference_url", None),
|
# Codex-style auto-import: before launching a fresh device-code
|
||||||
client_id=getattr(args, "client_id", None) or pconfig.client_id,
|
# flow, check the shared store for an existing Nous credential
|
||||||
scope=getattr(args, "scope", None) or pconfig.scope,
|
# from any other profile. If present, offer to rehydrate it.
|
||||||
open_browser=not getattr(args, "no_browser", False),
|
shared = _read_shared_nous_state()
|
||||||
timeout_seconds=timeout_seconds,
|
if shared:
|
||||||
insecure=insecure,
|
try:
|
||||||
ca_bundle=ca_bundle,
|
shared_path = _nous_shared_store_path()
|
||||||
min_key_ttl_seconds=5 * 60,
|
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"]
|
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)
|
_save_provider_state(auth_store, "nous", auth_state)
|
||||||
saved_to = _save_auth_store(auth_store)
|
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()
|
||||||
print("Login successful!")
|
print("Login successful!")
|
||||||
print(f" Auth state: {saved_to}")
|
print(f" Auth state: {saved_to}")
|
||||||
|
|||||||
@@ -245,6 +245,47 @@ def auth_add_command(args) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if provider == "nous":
|
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(
|
creds = auth_mod._nous_device_code_login(
|
||||||
portal_base_url=getattr(args, "portal_url", None),
|
portal_base_url=getattr(args, "portal_url", None),
|
||||||
inference_base_url=getattr(args, "inference_url", None),
|
inference_base_url=getattr(args, "inference_url", None),
|
||||||
|
|||||||
+15
-2
@@ -61,6 +61,9 @@ _EXCLUDED_NAMES = {
|
|||||||
"cron.pid",
|
"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:
|
def _should_exclude(rel_path: Path) -> bool:
|
||||||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
"""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)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with zf.open(member) as src, open(target, "wb") as dst:
|
with zf.open(member) as src, open(target, "wb") as dst:
|
||||||
dst.write(src.read())
|
dst.write(src.read())
|
||||||
|
if target.name in _SECRET_FILE_NAMES:
|
||||||
|
os.chmod(target, 0o600)
|
||||||
restored += 1
|
restored += 1
|
||||||
except (PermissionError, OSError) as exc:
|
except (PermissionError, OSError) as exc:
|
||||||
errors.append(f" {rel}: {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
|
Returns the number of files deleted. Only touches files matching
|
||||||
``pre-update-*.zip`` so hand-made zips dropped in the same directory
|
``pre-update-*.zip`` so hand-made zips dropped in the same directory
|
||||||
are never touched.
|
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:
|
if keep < 1:
|
||||||
keep = 0
|
keep = 1
|
||||||
if not backup_dir.exists():
|
if not backup_dir.exists():
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
+30
-4
@@ -64,7 +64,7 @@ class CommandDef:
|
|||||||
COMMAND_REGISTRY: list[CommandDef] = [
|
COMMAND_REGISTRY: list[CommandDef] = [
|
||||||
# Session
|
# Session
|
||||||
CommandDef("new", "Start a new session (fresh session ID + history)", "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",
|
CommandDef("clear", "Clear screen and start a new session", "Session",
|
||||||
cli_only=True),
|
cli_only=True),
|
||||||
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
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
|
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]:
|
def gateway_help_lines() -> list[str]:
|
||||||
"""Generate gateway help text lines from the registry."""
|
"""Generate gateway help text lines from the registry."""
|
||||||
overrides = _resolve_config_gates()
|
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
|
Telegram command names cannot contain hyphens, so they are replaced with
|
||||||
underscores. Aliases are skipped -- Telegram shows one menu entry per
|
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
|
Plugin-registered slash commands are included so plugins get native
|
||||||
autocomplete in Telegram without touching core code.
|
autocomplete in Telegram without touching core code.
|
||||||
@@ -465,10 +472,14 @@ def telegram_bot_commands() -> list[tuple[str, str]]:
|
|||||||
for cmd in COMMAND_REGISTRY:
|
for cmd in COMMAND_REGISTRY:
|
||||||
if not _is_gateway_available(cmd, overrides):
|
if not _is_gateway_available(cmd, overrides):
|
||||||
continue
|
continue
|
||||||
|
if _requires_argument(cmd.args_hint):
|
||||||
|
continue
|
||||||
tg_name = _sanitize_telegram_name(cmd.name)
|
tg_name = _sanitize_telegram_name(cmd.name)
|
||||||
if tg_name:
|
if tg_name:
|
||||||
result.append((tg_name, cmd.description))
|
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)
|
tg_name = _sanitize_telegram_name(name)
|
||||||
if tg_name:
|
if tg_name:
|
||||||
result.append((tg_name, description))
|
result.append((tg_name, description))
|
||||||
@@ -1115,6 +1126,12 @@ class SlashCommandCompleter(Completer):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
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
|
@staticmethod
|
||||||
def _completion_text(cmd_name: str, word: str) -> str:
|
def _completion_text(cmd_name: str, word: str) -> str:
|
||||||
"""Return replacement text for a completion.
|
"""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
|
returning ``help`` would be a no-op and prompt_toolkit suppresses the
|
||||||
menu. Appending a trailing space keeps the dropdown visible and makes
|
menu. Appending a trailing space keeps the dropdown visible and makes
|
||||||
backspacing retrigger it naturally.
|
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
|
@staticmethod
|
||||||
def _extract_path_word(text: str) -> str | None:
|
def _extract_path_word(text: str) -> str | None:
|
||||||
|
|||||||
@@ -1292,7 +1292,10 @@ DEFAULT_CONFIG = {
|
|||||||
# for a single update run.
|
# for a single update run.
|
||||||
"pre_update_backup": False,
|
"pre_update_backup": False,
|
||||||
# How many pre-update backup zips to retain. Older ones are pruned
|
# 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,
|
"backup_keep": 5,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -4682,7 +4685,9 @@ def set_config_value(key: str, value: str):
|
|||||||
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
"terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
|
||||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
"terminal.docker_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.timeout": "TERMINAL_TIMEOUT",
|
||||||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||||
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ def curses_checklist(
|
|||||||
flush_stdin()
|
flush_stdin()
|
||||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return cancel_returns
|
||||||
except Exception:
|
except Exception:
|
||||||
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
|
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
|
||||||
|
|
||||||
@@ -278,6 +280,8 @@ def curses_radiolist(
|
|||||||
flush_stdin()
|
flush_stdin()
|
||||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return cancel_returns
|
||||||
except Exception:
|
except Exception:
|
||||||
return _radio_numbered_fallback(title, items, selected, cancel_returns)
|
return _radio_numbered_fallback(title, items, selected, cancel_returns)
|
||||||
|
|
||||||
@@ -401,6 +405,8 @@ def curses_single_select(
|
|||||||
return None
|
return None
|
||||||
return result_holder[0]
|
return result_holder[0]
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
all_items = list(items) + [cancel_label]
|
all_items = list(items) + [cancel_label]
|
||||||
cancel_idx = len(items)
|
cancel_idx = len(items)
|
||||||
|
|||||||
+82
-7
@@ -1,12 +1,19 @@
|
|||||||
"""``hermes debug`` — debug tools for Hermes Agent.
|
"""``hermes debug`` debug tools for Hermes Agent.
|
||||||
|
|
||||||
Currently supports:
|
Currently supports:
|
||||||
hermes debug share Upload debug report (system info + logs) to a
|
hermes debug share Upload debug report (system info + logs) to a
|
||||||
paste service and print a shareable URL.
|
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 io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
@@ -19,6 +26,16 @@ from typing import Optional
|
|||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from utils import atomic_replace
|
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.
|
# 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
|
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(
|
def _capture_log_snapshot(
|
||||||
log_name: str,
|
log_name: str,
|
||||||
*,
|
*,
|
||||||
tail_lines: int,
|
tail_lines: int,
|
||||||
max_bytes: int = _MAX_LOG_BYTES,
|
max_bytes: int = _MAX_LOG_BYTES,
|
||||||
|
redact: bool = True,
|
||||||
) -> LogSnapshot:
|
) -> LogSnapshot:
|
||||||
"""Capture a log once and derive summary/full-log views from it.
|
"""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
|
The report tail and standalone log upload must come from the same file
|
||||||
snapshot. Otherwise a rotation/truncate between reads can make the report
|
snapshot. Otherwise a rotation/truncate between reads can make the report
|
||||||
look newer than the uploaded ``agent.log`` paste.
|
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)
|
log_path = _resolve_log_path(log_name)
|
||||||
if log_path is None:
|
if log_path is None:
|
||||||
@@ -438,18 +478,34 @@ def _capture_log_snapshot(
|
|||||||
if truncated:
|
if truncated:
|
||||||
full_text = f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{full_text}"
|
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)
|
return LogSnapshot(path=log_path, tail_text=tail_text, full_text=full_text)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return LogSnapshot(path=log_path, tail_text=f"(error reading: {exc})", full_text=None)
|
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]:
|
def _capture_default_log_snapshots(
|
||||||
"""Capture all logs used by debug-share exactly once."""
|
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)
|
errors_lines = min(log_lines, 100)
|
||||||
return {
|
return {
|
||||||
"agent": _capture_log_snapshot("agent", tail_lines=log_lines),
|
"agent": _capture_log_snapshot(
|
||||||
"errors": _capture_log_snapshot("errors", tail_lines=errors_lines),
|
"agent", tail_lines=log_lines, redact=redact
|
||||||
"gateway": _capture_log_snapshot("gateway", tail_lines=errors_lines),
|
),
|
||||||
|
"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)
|
log_lines = getattr(args, "lines", 200)
|
||||||
expiry = getattr(args, "expire", 7)
|
expiry = getattr(args, "expire", 7)
|
||||||
local_only = getattr(args, "local", False)
|
local_only = getattr(args, "local", False)
|
||||||
|
redact = not getattr(args, "no_redact", False)
|
||||||
|
|
||||||
if not local_only:
|
if not local_only:
|
||||||
print(_PRIVACY_NOTICE)
|
print(_PRIVACY_NOTICE)
|
||||||
@@ -539,8 +596,16 @@ def run_debug_share(args):
|
|||||||
print("Collecting debug report...")
|
print("Collecting debug report...")
|
||||||
|
|
||||||
# Capture dump once — prepended to every paste for context.
|
# 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()
|
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(
|
report = collect_debug_report(
|
||||||
log_lines=log_lines,
|
log_lines=log_lines,
|
||||||
@@ -556,6 +621,15 @@ def run_debug_share(args):
|
|||||||
if gateway_log:
|
if gateway_log:
|
||||||
gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + 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:
|
if local_only:
|
||||||
print(report)
|
print(report)
|
||||||
if agent_log:
|
if agent_log:
|
||||||
@@ -666,6 +740,7 @@ def run_debug(args):
|
|||||||
print(" --lines N Number of log lines to include (default: 200)")
|
print(" --lines N Number of log lines to include (default: 200)")
|
||||||
print(" --expire N Paste expiry in days (default: 7)")
|
print(" --expire N Paste expiry in days (default: 7)")
|
||||||
print(" --local Print report locally instead of uploading")
|
print(" --local Print report locally instead of uploading")
|
||||||
|
print(" --no-redact Disable upload-time secret redaction (default: redact)")
|
||||||
print()
|
print()
|
||||||
print("Options (delete):")
|
print("Options (delete):")
|
||||||
print(" <url> ... One or more paste URLs to 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"
|
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||||
if agent_browser_path.exists():
|
if agent_browser_path.exists():
|
||||||
check_ok("agent-browser (Node.js)", "(browser automation)")
|
check_ok("agent-browser (Node.js)", "(browser automation)")
|
||||||
|
elif shutil.which("agent-browser"):
|
||||||
|
check_ok("agent-browser", "(browser automation)")
|
||||||
else:
|
else:
|
||||||
if _is_termux():
|
if _is_termux():
|
||||||
check_info("agent-browser is not installed (expected in the tested Termux path)")
|
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),
|
("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),
|
("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),
|
("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", ("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),
|
("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),
|
("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),
|
("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
|
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:
|
def _append_unique_pid(pids: list[int], pid: int | None, exclude_pids: set[int]) -> None:
|
||||||
if pid is None or pid <= 0:
|
if pid is None or pid <= 0:
|
||||||
return
|
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
|
a live gateway when the PID file is stale/missing, and ``--all`` sweeps can
|
||||||
discover gateways outside the current profile.
|
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] = []
|
pids: list[int] = []
|
||||||
patterns = [
|
patterns = [
|
||||||
"hermes_cli.main gateway",
|
"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.")
|
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,
|
def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
|
||||||
all_profiles: bool = False) -> int:
|
all_profiles: bool = False) -> int:
|
||||||
"""Kill any running gateway processes. Returns count killed.
|
"""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")
|
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):
|
def systemd_start(system: bool = False):
|
||||||
system = _select_systemd_scope(system)
|
system = _select_systemd_scope(system)
|
||||||
if system:
|
if system:
|
||||||
@@ -1930,6 +1989,7 @@ def systemd_start(system: bool = False):
|
|||||||
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
|
# reachable (common on fresh RHEL/Debian SSH sessions without linger).
|
||||||
# Raises UserSystemdUnavailableError with a remediation message.
|
# Raises UserSystemdUnavailableError with a remediation message.
|
||||||
_preflight_user_systemd()
|
_preflight_user_systemd()
|
||||||
|
_require_service_installed("start", system=system)
|
||||||
refresh_systemd_unit_if_needed(system=system)
|
refresh_systemd_unit_if_needed(system=system)
|
||||||
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
|
_run_systemctl(["start", get_service_name()], system=system, check=True, timeout=30)
|
||||||
print(f"✓ {_service_scope_label(system).capitalize()} service started")
|
print(f"✓ {_service_scope_label(system).capitalize()} service started")
|
||||||
@@ -1940,6 +2000,7 @@ def systemd_stop(system: bool = False):
|
|||||||
system = _select_systemd_scope(system)
|
system = _select_systemd_scope(system)
|
||||||
if system:
|
if system:
|
||||||
_require_root_for_system_service("stop")
|
_require_root_for_system_service("stop")
|
||||||
|
_require_service_installed("stop", system=system)
|
||||||
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
|
_run_systemctl(["stop", get_service_name()], system=system, check=True, timeout=90)
|
||||||
print(f"✓ {_service_scope_label(system).capitalize()} service stopped")
|
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")
|
_require_root_for_system_service("restart")
|
||||||
else:
|
else:
|
||||||
_preflight_user_systemd()
|
_preflight_user_systemd()
|
||||||
|
_require_service_installed("restart", system=system)
|
||||||
refresh_systemd_unit_if_needed(system=system)
|
refresh_systemd_unit_if_needed(system=system)
|
||||||
from gateway.status import get_running_pid
|
from gateway.status import get_running_pid
|
||||||
|
|
||||||
@@ -2443,6 +2505,20 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
|||||||
"""
|
"""
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
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
|
from gateway.run import start_gateway
|
||||||
|
|
||||||
print("┌─────────────────────────────────────────────────────────┐")
|
print("┌─────────────────────────────────────────────────────────┐")
|
||||||
@@ -4456,6 +4532,9 @@ def _gateway_command_inner(args):
|
|||||||
print(" hermes gateway install # Install as user service")
|
print(" hermes gateway install # Install as user service")
|
||||||
print(" sudo hermes gateway install --system # Install as boot-time system 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":
|
elif subcmd == "migrate-legacy":
|
||||||
# Stop, disable, and remove legacy Hermes gateway unit files from
|
# Stop, disable, and remove legacy Hermes gateway unit files from
|
||||||
# pre-rename installs (e.g. hermes.service). Profile units and
|
# pre-rename installs (e.g. hermes.service). Profile units and
|
||||||
|
|||||||
+309
-1
@@ -169,11 +169,93 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||||||
"or docs/hermes-kanban-v1-spec.pdf for the full design."
|
"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")
|
sub = kanban_parser.add_subparsers(dest="kanban_action")
|
||||||
|
|
||||||
# --- init ---
|
# --- init ---
|
||||||
sub.add_parser("init", help="Create kanban.db if missing (idempotent)")
|
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 ---
|
# --- create ---
|
||||||
p_create = sub.add_parser("create", help="Create a new task")
|
p_create = sub.add_parser("create", help="Create a new task")
|
||||||
p_create.add_argument("title", help="Task title")
|
p_create.add_argument("title", help="Task title")
|
||||||
@@ -366,7 +448,7 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
|||||||
# --- log ---
|
# --- log ---
|
||||||
p_log = sub.add_parser(
|
p_log = sub.add_parser(
|
||||||
"log",
|
"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("task_id")
|
||||||
p_log.add_argument("--tail", type=int, default=None,
|
p_log.add_argument("--tail", type=int, default=None,
|
||||||
@@ -442,6 +524,38 @@ def kanban_command(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
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
|
# Auto-initialize the DB before dispatching any subcommand. init_db
|
||||||
# is idempotent, so running it every invocation is cheap (one
|
# is idempotent, so running it every invocation is cheap (one
|
||||||
# SELECT against sqlite_master when tables already exist) and
|
# SELECT against sqlite_master when tables already exist) and
|
||||||
@@ -513,6 +627,185 @@ def _profile_author() -> str:
|
|||||||
return "user"
|
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]:
|
def _parse_duration(val) -> Optional[int]:
|
||||||
"""Parse ``30s`` / ``5m`` / ``2h`` / ``1d`` or a raw integer → seconds.
|
"""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):
|
if getattr(args, "json", False):
|
||||||
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
|
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
|
||||||
return 0
|
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:
|
if not tasks:
|
||||||
print("(no matching tasks)")
|
print("(no matching tasks)")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+602
-41
@@ -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:
|
In a fresh install the board lives at ``<root>/kanban.db`` where
|
||||||
multiple profiles on the same machine all see the same board, which IS the
|
``<root>`` is the **shared Hermes root** (the parent of any active
|
||||||
coordination primitive).
|
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,
|
Schema is intentionally small: tasks, task_links, task_comments,
|
||||||
task_events. The ``workspace_kind`` field decouples coordination from git
|
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
|
``tasks.claim_lock``. SQLite serializes writers via its WAL lock, so at
|
||||||
most one claimer can win any given task. Losers observe zero affected
|
most one claimer can win any given task. Losers observe zero affected
|
||||||
rows and move on -- no retry loops, no distributed-lock machinery.
|
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
|
from __future__ import annotations
|
||||||
@@ -22,6 +73,7 @@ from __future__ import annotations
|
|||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
@@ -61,16 +113,438 @@ _CTX_MAX_COMMENT_BYTES = 2 * 1024 # 2 KB per comment
|
|||||||
# Paths
|
# Paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def kanban_db_path() -> Path:
|
DEFAULT_BOARD = "default"
|
||||||
"""Return the path to ``kanban.db`` inside the active HERMES_HOME."""
|
|
||||||
from hermes_constants import get_hermes_home
|
# Slug validator: lowercase alphanumerics, digits, hyphens; 1–64 chars.
|
||||||
return get_hermes_home() / "kanban.db"
|
# 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:
|
def _normalize_board_slug(slug: Optional[str]) -> Optional[str]:
|
||||||
"""Return the directory under which ``scratch`` workspaces are created."""
|
"""Lowercase + strip a slug; validate; return ``None`` for empty."""
|
||||||
from hermes_constants import get_hermes_home
|
if slug is None:
|
||||||
return get_hermes_home() / "kanban" / "workspaces"
|
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()
|
_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.
|
"""Open (and initialize if needed) the kanban DB.
|
||||||
|
|
||||||
WAL mode is enabled on every connection; it's a no-op after the first
|
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()`
|
fresh installs and test harnesses that construct `connect()`
|
||||||
directly don't have to remember a separate init step. Subsequent
|
directly don't have to remember a separate init step. Subsequent
|
||||||
connections skip the schema check via a module-level path cache.
|
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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
resolved = str(path.resolve())
|
resolved = str(path.resolve())
|
||||||
needs_init = resolved not in _INITIALIZED_PATHS
|
needs_init = resolved not in _INITIALIZED_PATHS
|
||||||
@@ -398,7 +887,11 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection:
|
|||||||
return conn
|
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.
|
"""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
|
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
|
external tools that upgrade an old DB file — can call this to
|
||||||
force re-migration.
|
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)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
resolved = str(path.resolve())
|
resolved = str(path.resolve())
|
||||||
# Clear the cache entry so the underlying connect() re-runs the
|
# Clear the cache entry so the underlying connect() re-runs the
|
||||||
@@ -590,6 +1086,15 @@ def _claimer_id() -> str:
|
|||||||
# Task creation / mutation
|
# 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(
|
def create_task(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
@@ -631,6 +1136,7 @@ def create_task(
|
|||||||
(e.g. ``skills=["translation"]`` so the worker loads the
|
(e.g. ``skills=["translation"]`` so the worker loads the
|
||||||
translation skill regardless of the profile's default config).
|
translation skill regardless of the profile's default config).
|
||||||
"""
|
"""
|
||||||
|
assignee = _canonical_assignee(assignee)
|
||||||
if not title or not title.strip():
|
if not title or not title.strip():
|
||||||
raise ValueError("title is required")
|
raise ValueError("title is required")
|
||||||
if workspace_kind not in VALID_WORKSPACE_KINDS:
|
if workspace_kind not in VALID_WORKSPACE_KINDS:
|
||||||
@@ -795,7 +1301,7 @@ def list_tasks(
|
|||||||
params: list[Any] = []
|
params: list[Any] = []
|
||||||
if assignee is not None:
|
if assignee is not None:
|
||||||
query += " AND assignee = ?"
|
query += " AND assignee = ?"
|
||||||
params.append(assignee)
|
params.append(_canonical_assignee(assignee))
|
||||||
if status is not None:
|
if status is not None:
|
||||||
if status not in VALID_STATUSES:
|
if status not in VALID_STATUSES:
|
||||||
raise ValueError(f"status must be one of {sorted(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).
|
Refuses to reassign a task that's currently running (claim_lock set).
|
||||||
Reassign after the current run completes if needed.
|
Reassign after the current run completes if needed.
|
||||||
"""
|
"""
|
||||||
|
profile = _canonical_assignee(profile)
|
||||||
with write_txn(conn):
|
with write_txn(conn):
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)
|
"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
|
# 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.
|
"""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
|
- ``dir:<path>``: the path stored in ``workspace_path``. Created
|
||||||
if missing. MUST be absolute — relative paths are rejected to
|
if missing. MUST be absolute — relative paths are rejected to
|
||||||
prevent confused-deputy traversal where ``../../../tmp/attacker``
|
prevent confused-deputy traversal where ``../../../tmp/attacker``
|
||||||
resolves against the dispatcher's CWD instead of a meaningful
|
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.
|
compute the absolute path themselves.
|
||||||
- ``worktree``: a git worktree at ``workspace_path``. Not created
|
- ``worktree``: a git worktree at ``workspace_path``. Not created
|
||||||
automatically in v1 -- the kanban-worker skill documents
|
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"
|
f"{task.workspace_path!r}; workspace paths must be absolute"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
p = workspaces_root() / task.id
|
p = workspaces_root(board=board) / task.id
|
||||||
p.mkdir(parents=True, exist_ok=True)
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
return p
|
return p
|
||||||
if kind == "dir":
|
if kind == "dir":
|
||||||
@@ -1957,6 +2467,7 @@ def dispatch_once(
|
|||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
max_spawn: Optional[int] = None,
|
max_spawn: Optional[int] = None,
|
||||||
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
|
failure_limit: int = DEFAULT_SPAWN_FAILURE_LIMIT,
|
||||||
|
board: Optional[str] = None,
|
||||||
) -> DispatchResult:
|
) -> DispatchResult:
|
||||||
"""Run one dispatcher tick.
|
"""Run one dispatcher tick.
|
||||||
|
|
||||||
@@ -1965,15 +2476,17 @@ def dispatch_once(
|
|||||||
2. Reclaim crashed running tasks (host-local PID no longer alive).
|
2. Reclaim crashed running tasks (host-local PID no longer alive).
|
||||||
3. Promote todo -> ready where all parents are done.
|
3. Promote todo -> ready where all parents are done.
|
||||||
4. For each ready task with an assignee, atomically claim and call
|
4. For each ready task with an assignee, atomically claim and call
|
||||||
``spawn_fn(task, workspace_path) -> Optional[int]``. The return
|
``spawn_fn(task, workspace_path, board) -> Optional[int]``. The
|
||||||
value (if any) is recorded as ``worker_pid`` so subsequent ticks
|
return value (if any) is recorded as ``worker_pid`` so subsequent
|
||||||
can detect crashes before the TTL expires.
|
ticks can detect crashes before the TTL expires.
|
||||||
|
|
||||||
Spawn failures are counted per-task. After ``failure_limit`` consecutive
|
Spawn failures are counted per-task. After ``failure_limit`` consecutive
|
||||||
failures the task is auto-blocked with the last error as its reason —
|
failures the task is auto-blocked with the last error as its reason —
|
||||||
prevents the dispatcher from thrashing forever on an unfixable task.
|
prevents the dispatcher from thrashing forever on an unfixable task.
|
||||||
|
|
||||||
``spawn_fn`` defaults to ``_default_spawn``. Tests pass a stub.
|
``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 = DispatchResult()
|
||||||
result.reclaimed = release_stale_claims(conn)
|
result.reclaimed = release_stale_claims(conn)
|
||||||
@@ -2000,7 +2513,7 @@ def dispatch_once(
|
|||||||
if claimed is None:
|
if claimed is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
workspace = resolve_workspace(claimed)
|
workspace = resolve_workspace(claimed, board=board)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
auto = _record_spawn_failure(
|
auto = _record_spawn_failure(
|
||||||
conn, claimed.id, f"workspace: {exc}",
|
conn, claimed.id, f"workspace: {exc}",
|
||||||
@@ -2013,7 +2526,18 @@ def dispatch_once(
|
|||||||
set_workspace_path(conn, claimed.id, str(workspace))
|
set_workspace_path(conn, claimed.id, str(workspace))
|
||||||
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
|
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
|
||||||
try:
|
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:
|
if pid:
|
||||||
_set_worker_pid(conn, claimed.id, int(pid))
|
_set_worker_pid(conn, claimed.id, int(pid))
|
||||||
_clear_spawn_failures(conn, claimed.id)
|
_clear_spawn_failures(conn, claimed.id)
|
||||||
@@ -2052,33 +2576,60 @@ def _rotate_worker_log(log_path: Path, max_bytes: int) -> None:
|
|||||||
pass
|
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.
|
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
|
||||||
|
|
||||||
Returns the spawned child's PID so the dispatcher can detect crashes
|
Returns the spawned child's PID so the dispatcher can detect crashes
|
||||||
before the claim TTL expires. The child's completion is still observed
|
before the claim TTL expires. The child's completion is still observed
|
||||||
via the ``complete`` / ``block`` transitions the worker writes itself;
|
via the ``complete`` / ``block`` transitions the worker writes itself;
|
||||||
the PID check is a safety net for crashes, OOM kills, and Ctrl+C.
|
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
|
import subprocess
|
||||||
if not task.assignee:
|
if not task.assignee:
|
||||||
raise ValueError(f"task {task.id} has no 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}"
|
prompt = f"work kanban task {task.id}"
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
if task.tenant:
|
if task.tenant:
|
||||||
env["HERMES_TENANT"] = task.tenant
|
env["HERMES_TENANT"] = task.tenant
|
||||||
env["HERMES_KANBAN_TASK"] = task.id
|
env["HERMES_KANBAN_TASK"] = task.id
|
||||||
env["HERMES_KANBAN_WORKSPACE"] = workspace
|
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_PROFILE is the author the kanban_comment tool defaults to.
|
||||||
# `hermes -p <assignee>` activates the profile, but the env var is
|
# `hermes -p <assignee>` activates the profile, but the env var is
|
||||||
# what the tool reads — set it explicitly here so comments are
|
# what the tool reads — set it explicitly here so comments are
|
||||||
# attributed correctly regardless of how the child loads config.
|
# attributed correctly regardless of how the child loads config.
|
||||||
env["HERMES_PROFILE"] = task.assignee
|
env["HERMES_PROFILE"] = profile_arg
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"hermes",
|
"hermes",
|
||||||
"-p", task.assignee,
|
"-p", profile_arg,
|
||||||
# Auto-load the kanban-worker skill so every dispatched worker
|
# Auto-load the kanban-worker skill so every dispatched worker
|
||||||
# has the pattern library (good summary/metadata shapes, retry
|
# has the pattern library (good summary/metadata shapes, retry
|
||||||
# diagnostics, block-reason examples) in its context, even if
|
# diagnostics, block-reason examples) in its context, even if
|
||||||
@@ -2104,9 +2655,11 @@ def _default_spawn(task: Task, workspace: str) -> Optional[int]:
|
|||||||
"chat",
|
"chat",
|
||||||
"-q", prompt,
|
"-q", prompt,
|
||||||
])
|
])
|
||||||
# Redirect output to a per-task log under HERMES_HOME/kanban/logs/.
|
# Redirect output to a per-task log under <board-root>/logs/.
|
||||||
from hermes_constants import get_hermes_home
|
# Anchored at the board root (not the shared kanban root), so
|
||||||
log_dir = get_hermes_home() / "kanban" / "logs"
|
# `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_dir.mkdir(parents=True, exist_ok=True)
|
||||||
log_path = log_dir / f"{task.id}.log"
|
log_path = log_dir / f"{task.id}.log"
|
||||||
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
|
_rotate_worker_log(log_path, DEFAULT_LOG_ROTATE_BYTES)
|
||||||
@@ -2587,12 +3140,14 @@ def gc_events(
|
|||||||
|
|
||||||
def gc_worker_logs(
|
def gc_worker_logs(
|
||||||
*, older_than_seconds: int = 30 * 24 * 3600,
|
*, older_than_seconds: int = 30 * 24 * 3600,
|
||||||
|
board: Optional[str] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Delete worker log files older than ``older_than_seconds``. Returns
|
"""Delete worker log files older than ``older_than_seconds``. Returns
|
||||||
the number of files removed. Kept separate from ``gc_events`` because
|
the number of files removed. Kept separate from ``gc_events`` because
|
||||||
log files live on disk, not in SQLite."""
|
log files live on disk, not in SQLite. Scoped to ``board`` (defaults
|
||||||
from hermes_constants import get_hermes_home
|
to the active board) — per-board isolation means deleting logs from
|
||||||
log_dir = get_hermes_home() / "kanban" / "logs"
|
board A cannot touch board B's logs."""
|
||||||
|
log_dir = worker_logs_dir(board=board)
|
||||||
if not log_dir.exists():
|
if not log_dir.exists():
|
||||||
return 0
|
return 0
|
||||||
cutoff = time.time() - older_than_seconds
|
cutoff = time.time() - older_than_seconds
|
||||||
@@ -2611,20 +3166,25 @@ def gc_worker_logs(
|
|||||||
# Worker log accessor
|
# 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
|
"""Return the path to a worker's log file. The file may not exist
|
||||||
(task never spawned, or log already GC'd)."""
|
(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"
|
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(
|
def read_worker_log(
|
||||||
task_id: str, *, tail_bytes: Optional[int] = None,
|
task_id: str, *, tail_bytes: Optional[int] = None,
|
||||||
|
board: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Read the worker log for ``task_id``. Returns None if the file
|
"""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
|
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)."""
|
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():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -2661,7 +3221,8 @@ def list_profiles_on_disk() -> list[str]:
|
|||||||
``config.yaml`` — a bare dir without config isn't a real profile.
|
``config.yaml`` — a bare dir without config isn't a real profile.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
home = Path.home() / ".hermes" / "profiles"
|
from hermes_constants import get_default_hermes_root
|
||||||
|
home = get_default_hermes_root() / "profiles"
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
if not home.is_dir():
|
if not home.is_dir():
|
||||||
|
|||||||
+64
-13
@@ -114,6 +114,16 @@ def _apply_profile_override() -> None:
|
|||||||
consume = 1
|
consume = 1
|
||||||
break
|
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.
|
# 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
|
# This lets child processes (relaunch, subprocess) inherit the parent's
|
||||||
# profile choice without having to pass --profile again.
|
# 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:
|
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 _tui_need_npm_install(tui_dir):
|
||||||
if not os.environ.get("HERMES_QUIET"):
|
if not os.environ.get("HERMES_QUIET"):
|
||||||
print("Installing TUI dependencies…")
|
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(
|
result = subprocess.run(
|
||||||
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
[npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"],
|
||||||
cwd=str(tui_dir),
|
cwd=str(tui_dir),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env={**os.environ, "CI": "1"},
|
env={**os.environ, "CI": "1"},
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
err = (result.stderr or "").strip()
|
combined = f"{result.stdout or ''}\n{result.stderr or ''}".strip()
|
||||||
preview = "\n".join(err.splitlines()[-30:])
|
preview = "\n".join(combined.splitlines()[-30:])
|
||||||
print("npm install failed.")
|
print("npm install failed.")
|
||||||
if preview:
|
if preview:
|
||||||
print(preview)
|
print(preview)
|
||||||
@@ -3399,10 +3423,10 @@ def _model_flow_named_custom(config, provider_info):
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
print("Fetching available models...")
|
print("Fetching available models...")
|
||||||
models = fetch_api_models(
|
fetch_kwargs = {"timeout": 8.0}
|
||||||
api_key, base_url, timeout=8.0,
|
if api_mode:
|
||||||
api_mode=api_mode or None,
|
fetch_kwargs["api_mode"] = api_mode
|
||||||
)
|
models = fetch_api_models(api_key, base_url, **fetch_kwargs)
|
||||||
|
|
||||||
if models:
|
if models:
|
||||||
default_idx = 0
|
default_idx = 0
|
||||||
@@ -6477,13 +6501,29 @@ def _cmd_update_check():
|
|||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
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(
|
fetch_result = subprocess.run(
|
||||||
git_cmd + ["fetch", "origin"],
|
git_cmd + ["fetch", "upstream"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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:
|
if fetch_result.returncode != 0:
|
||||||
stderr = fetch_result.stderr.strip()
|
stderr = fetch_result.stderr.strip()
|
||||||
if "Could not resolve host" in stderr or "unable to access" in stderr:
|
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:
|
elif "Authentication failed" in stderr or "could not read Username" in stderr:
|
||||||
print("✗ Authentication failed — check your git credentials or SSH key.")
|
print("✗ Authentication failed — check your git credentials or SSH key.")
|
||||||
else:
|
else:
|
||||||
print("✗ Failed to fetch from origin.")
|
print("✗ Failed to fetch.")
|
||||||
if stderr:
|
if stderr:
|
||||||
print(f" {stderr.splitlines()[0]}")
|
print(f" {stderr.splitlines()[0]}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
rev_result = subprocess.run(
|
rev_result = subprocess.run(
|
||||||
git_cmd + ["rev-list", "HEAD..origin/main", "--count"],
|
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
|
||||||
cwd=PROJECT_ROOT,
|
cwd=PROJECT_ROOT,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -6509,7 +6549,7 @@ def _cmd_update_check():
|
|||||||
print("✓ Already up to date.")
|
print("✓ Already up to date.")
|
||||||
else:
|
else:
|
||||||
commits_word = "commit" if behind == 1 else "commits"
|
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
|
from hermes_cli.config import recommended_update_command
|
||||||
print(f" Run '{recommended_update_command()}' to install.")
|
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 --lines 500 Include more log lines
|
||||||
hermes debug share --expire 30 Keep paste for 30 days
|
hermes debug share --expire 30 Keep paste for 30 days
|
||||||
hermes debug share --local Print report locally (no upload)
|
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
|
hermes debug delete <url> Delete a previously uploaded paste
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
@@ -8922,6 +8963,16 @@ Examples:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print the report locally instead of uploading",
|
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_parser = debug_sub.add_parser(
|
||||||
"delete",
|
"delete",
|
||||||
help="Delete a paste uploaded by 'hermes debug share'",
|
help="Delete a paste uploaded by 'hermes debug share'",
|
||||||
|
|||||||
+22
-10
@@ -904,6 +904,26 @@ def switch_model(
|
|||||||
if any(m.get("name") == new_model for m in cfg_models if isinstance(m, dict)):
|
if any(m.get("name") == new_model for m in cfg_models if isinstance(m, dict)):
|
||||||
override = True
|
override = True
|
||||||
break
|
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:
|
if override:
|
||||||
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
|
validation = {"accepted": True, "persist": True, "recognized": False, "message": validation.get("message", "")}
|
||||||
else:
|
else:
|
||||||
@@ -1244,11 +1264,7 @@ def list_authenticated_providers(
|
|||||||
from hermes_cli.auth import _load_auth_store
|
from hermes_cli.auth import _load_auth_store
|
||||||
store = _load_auth_store()
|
store = _load_auth_store()
|
||||||
providers_store = store.get("providers", {})
|
providers_store = store.get("providers", {})
|
||||||
pool_store = store.get("credential_pool", {})
|
if store and (pid in providers_store or hermes_slug in providers_store):
|
||||||
if store and (
|
|
||||||
pid in providers_store or hermes_slug in providers_store
|
|
||||||
or pid in pool_store or hermes_slug in pool_store
|
|
||||||
):
|
|
||||||
has_creds = True
|
has_creds = True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Auth store check failed for %s: %s", pid, 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
|
from hermes_cli.auth import _load_auth_store
|
||||||
_cp_store = _load_auth_store()
|
_cp_store = _load_auth_store()
|
||||||
_cp_providers_store = _cp_store.get("providers", {})
|
_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:
|
||||||
if _cp_store and (
|
|
||||||
_cp.slug in _cp_providers_store
|
|
||||||
or _cp.slug in _cp_pool_store
|
|
||||||
):
|
|
||||||
_cp_has_creds = True
|
_cp_has_creds = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
+17
-6
@@ -1740,10 +1740,20 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _is_anthropic_fast_model(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 ""))
|
raw = _strip_vendor_prefix(str(model_id or ""))
|
||||||
base = raw.split(":")[0]
|
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:
|
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.",
|
"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.
|
# Try probing with correct auth for the api_mode.
|
||||||
if api_mode == "anthropic_messages":
|
if api_mode == "anthropic_messages":
|
||||||
probe = probe_api_models(api_key, base_url, api_mode=api_mode)
|
probe = probe_api_models(api_key, base_url, api_mode=api_mode)
|
||||||
@@ -3185,11 +3195,12 @@ def validate_requested_model(
|
|||||||
if suggestions:
|
if suggestions:
|
||||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||||
return {
|
return {
|
||||||
"accepted": False,
|
"accepted": True,
|
||||||
"persist": False,
|
"persist": True,
|
||||||
"recognized": False,
|
"recognized": False,
|
||||||
"message": (
|
"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}"
|
f"{suggestion_text}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-73
@@ -179,8 +179,33 @@ def _get_wrapper_dir() -> Path:
|
|||||||
# Validation
|
# 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:
|
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":
|
if name == "default":
|
||||||
return # special alias for ~/.hermes
|
return # special alias for ~/.hermes
|
||||||
if not _PROFILE_ID_RE.match(name):
|
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:
|
def get_profile_dir(name: str) -> Path:
|
||||||
"""Resolve a profile name to its HERMES_HOME directory."""
|
"""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_default_hermes_home()
|
||||||
return _get_profiles_root() / name
|
return _get_profiles_root() / canon
|
||||||
|
|
||||||
|
|
||||||
def profile_exists(name: str) -> bool:
|
def profile_exists(name: str) -> bool:
|
||||||
"""Check whether a profile directory exists."""
|
"""Check whether a profile directory exists."""
|
||||||
if name == "default":
|
canon = normalize_profile_name(name)
|
||||||
|
if canon == "default":
|
||||||
return True
|
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.
|
Checks: reserved names, hermes subcommands, existing binaries in PATH.
|
||||||
"""
|
"""
|
||||||
if name in _RESERVED_NAMES:
|
canon = normalize_profile_name(name)
|
||||||
return f"'{name}' is a reserved name"
|
if canon in _RESERVED_NAMES:
|
||||||
if name in _HERMES_SUBCOMMANDS:
|
return f"'{canon}' is a reserved name"
|
||||||
return f"'{name}' conflicts with a hermes subcommand"
|
if canon in _HERMES_SUBCOMMANDS:
|
||||||
|
return f"'{canon}' conflicts with a hermes subcommand"
|
||||||
|
|
||||||
# Check existing commands in PATH
|
# Check existing commands in PATH
|
||||||
wrapper_dir = _get_wrapper_dir()
|
wrapper_dir = _get_wrapper_dir()
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
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:
|
if result.returncode == 0:
|
||||||
existing_path = result.stdout.strip()
|
existing_path = result.stdout.strip()
|
||||||
# Allow overwriting our own wrappers
|
# Allow overwriting our own wrappers
|
||||||
if existing_path == str(wrapper_dir / name):
|
if existing_path == str(wrapper_dir / canon):
|
||||||
try:
|
try:
|
||||||
content = (wrapper_dir / name).read_text()
|
content = (wrapper_dir / canon).read_text()
|
||||||
if "hermes -p" in content:
|
if "hermes -p" in content:
|
||||||
return None # it's our wrapper, safe to overwrite
|
return None # it's our wrapper, safe to overwrite
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
pass
|
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.
|
Returns the path to the created wrapper, or None if creation failed.
|
||||||
"""
|
"""
|
||||||
|
canon = normalize_profile_name(name)
|
||||||
wrapper_dir = _get_wrapper_dir()
|
wrapper_dir = _get_wrapper_dir()
|
||||||
try:
|
try:
|
||||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
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}")
|
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
wrapper_path = wrapper_dir / name
|
wrapper_path = wrapper_dir / canon
|
||||||
try:
|
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)
|
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
return wrapper_path
|
return wrapper_path
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -271,7 +300,7 @@ def create_wrapper_script(name: str) -> Optional[Path]:
|
|||||||
|
|
||||||
def remove_wrapper_script(name: str) -> bool:
|
def remove_wrapper_script(name: str) -> bool:
|
||||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
"""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():
|
if wrapper_path.exists():
|
||||||
try:
|
try:
|
||||||
# Verify it's our wrapper before removing
|
# Verify it's our wrapper before removing
|
||||||
@@ -421,16 +450,17 @@ def create_profile(
|
|||||||
Path
|
Path
|
||||||
The newly created profile directory.
|
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(
|
raise ValueError(
|
||||||
"Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)."
|
"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():
|
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
|
# Resolve clone source
|
||||||
source_dir = None
|
source_dir = None
|
||||||
@@ -440,6 +470,7 @@ def create_profile(
|
|||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
source_dir = get_hermes_home()
|
source_dir = get_hermes_home()
|
||||||
else:
|
else:
|
||||||
|
clone_from = normalize_profile_name(clone_from)
|
||||||
validate_profile_name(clone_from)
|
validate_profile_name(clone_from)
|
||||||
source_dir = get_profile_dir(clone_from)
|
source_dir = get_profile_dir(clone_from)
|
||||||
if not source_dir.is_dir():
|
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.
|
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(
|
raise ValueError(
|
||||||
"Cannot delete the default profile (~/.hermes).\n"
|
"Cannot delete the default profile (~/.hermes).\n"
|
||||||
"To remove everything, use: hermes uninstall"
|
"To remove everything, use: hermes uninstall"
|
||||||
)
|
)
|
||||||
|
|
||||||
profile_dir = get_profile_dir(name)
|
profile_dir = get_profile_dir(canon)
|
||||||
if not profile_dir.is_dir():
|
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
|
# Show what will be deleted
|
||||||
model, provider = _read_config_model(profile_dir)
|
model, provider = _read_config_model(profile_dir)
|
||||||
gw_running = _check_gateway_running(profile_dir)
|
gw_running = _check_gateway_running(profile_dir)
|
||||||
skill_count = _count_skills(profile_dir)
|
skill_count = _count_skills(profile_dir)
|
||||||
|
|
||||||
print(f"\nProfile: {name}")
|
print(f"\nProfile: {canon}")
|
||||||
print(f"Path: {profile_dir}")
|
print(f"Path: {profile_dir}")
|
||||||
if model:
|
if model:
|
||||||
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
|
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
|
# Check for service
|
||||||
wrapper_path = _get_wrapper_dir() / name
|
wrapper_path = _get_wrapper_dir() / canon
|
||||||
has_wrapper = wrapper_path.exists()
|
has_wrapper = wrapper_path.exists()
|
||||||
if has_wrapper:
|
if has_wrapper:
|
||||||
items.append(f"Command alias ({wrapper_path})")
|
items.append(f"Command alias ({wrapper_path})")
|
||||||
@@ -584,16 +616,16 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||||||
if not yes:
|
if not yes:
|
||||||
print()
|
print()
|
||||||
try:
|
try:
|
||||||
confirm = input(f"Type '{name}' to confirm: ").strip()
|
confirm = input(f"Type '{canon}' to confirm: ").strip()
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
print("\nCancelled.")
|
print("\nCancelled.")
|
||||||
return profile_dir
|
return profile_dir
|
||||||
if confirm != name:
|
if confirm != canon:
|
||||||
print("Cancelled.")
|
print("Cancelled.")
|
||||||
return profile_dir
|
return profile_dir
|
||||||
|
|
||||||
# 1. Disable service (prevents auto-restart)
|
# 1. Disable service (prevents auto-restart)
|
||||||
_cleanup_gateway_service(name, profile_dir)
|
_cleanup_gateway_service(canon, profile_dir)
|
||||||
|
|
||||||
# 2. Stop running gateway
|
# 2. Stop running gateway
|
||||||
if gw_running:
|
if gw_running:
|
||||||
@@ -601,7 +633,7 @@ def delete_profile(name: str, yes: bool = False) -> Path:
|
|||||||
|
|
||||||
# 3. Remove wrapper script
|
# 3. Remove wrapper script
|
||||||
if has_wrapper:
|
if has_wrapper:
|
||||||
if remove_wrapper_script(name):
|
if remove_wrapper_script(canon):
|
||||||
print(f"✓ Removed {wrapper_path}")
|
print(f"✓ Removed {wrapper_path}")
|
||||||
|
|
||||||
# 4. Remove profile directory
|
# 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
|
# 5. Clear active_profile if it pointed to this profile
|
||||||
try:
|
try:
|
||||||
active = get_active_profile()
|
active = get_active_profile()
|
||||||
if active == name:
|
if active == canon:
|
||||||
set_active_profile("default")
|
set_active_profile("default")
|
||||||
print("✓ Active profile reset to default")
|
print("✓ Active profile reset to default")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
print(f"\nProfile '{name}' deleted.")
|
print(f"\nProfile '{canon}' deleted.")
|
||||||
return profile_dir
|
return profile_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -730,22 +762,23 @@ def set_active_profile(name: str) -> None:
|
|||||||
|
|
||||||
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
|
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
|
||||||
"""
|
"""
|
||||||
validate_profile_name(name)
|
canon = normalize_profile_name(name)
|
||||||
if name != "default" and not profile_exists(name):
|
validate_profile_name(canon)
|
||||||
|
if canon != "default" and not profile_exists(canon):
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"Profile '{name}' does not exist. "
|
f"Profile '{canon}' does not exist. "
|
||||||
f"Create it with: hermes profile create {name}"
|
f"Create it with: hermes profile create {canon}"
|
||||||
)
|
)
|
||||||
|
|
||||||
path = _get_active_profile_path()
|
path = _get_active_profile_path()
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if name == "default":
|
if canon == "default":
|
||||||
# Remove the file to indicate default
|
# Remove the file to indicate default
|
||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
else:
|
else:
|
||||||
# Atomic write
|
# Atomic write
|
||||||
tmp = path.with_suffix(".tmp")
|
tmp = path.with_suffix(".tmp")
|
||||||
tmp.write_text(name + "\n")
|
tmp.write_text(canon + "\n")
|
||||||
tmp.replace(path)
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
@@ -811,16 +844,17 @@ def export_profile(name: str, output_path: str) -> Path:
|
|||||||
"""
|
"""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
validate_profile_name(name)
|
canon = normalize_profile_name(name)
|
||||||
profile_dir = get_profile_dir(name)
|
validate_profile_name(canon)
|
||||||
|
profile_dir = get_profile_dir(canon)
|
||||||
if not profile_dir.is_dir():
|
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)
|
output = Path(output_path)
|
||||||
# shutil.make_archive wants the base name without extension
|
# shutil.make_archive wants the base name without extension
|
||||||
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
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
|
# The default profile IS ~/.hermes itself — its parent is ~/ and its
|
||||||
# directory name is ".hermes", not "default". We stage a clean copy
|
# directory name is ".hermes", not "default". We stage a clean copy
|
||||||
# under a temp dir so the archive contains ``default/...``.
|
# 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
|
# Named profiles — stage a filtered copy to exclude credentials
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
staged = Path(tmpdir) / name
|
staged = Path(tmpdir) / canon
|
||||||
_CREDENTIAL_FILES = {"auth.json", ".env"}
|
_CREDENTIAL_FILES = {"auth.json", ".env"}
|
||||||
shutil.copytree(
|
shutil.copytree(
|
||||||
profile_dir,
|
profile_dir,
|
||||||
staged,
|
staged,
|
||||||
ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
|
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)
|
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
|
# Archives exported from the default profile have "default/" as top-level
|
||||||
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
||||||
# that and guide the user toward a named profile.
|
# 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(
|
raise ValueError(
|
||||||
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
|
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
|
||||||
"Specify a different name: hermes profile import <archive> --name <name>"
|
"Specify a different name: hermes profile import <archive> --name <name>"
|
||||||
)
|
)
|
||||||
|
|
||||||
validate_profile_name(inferred_name)
|
profile_dir = get_profile_dir(canon)
|
||||||
profile_dir = get_profile_dir(inferred_name)
|
|
||||||
if profile_dir.exists():
|
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 = _get_profiles_root()
|
||||||
profiles_root.mkdir(parents=True, exist_ok=True)
|
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
|
final_source = extracted
|
||||||
if archive_root != inferred_name:
|
if archive_root != canon:
|
||||||
final_source = staging_root / inferred_name
|
final_source = staging_root / canon
|
||||||
extracted.rename(final_source)
|
extracted.rename(final_source)
|
||||||
|
|
||||||
shutil.move(str(final_source), str(profile_dir))
|
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.
|
Returns the new profile directory.
|
||||||
"""
|
"""
|
||||||
validate_profile_name(old_name)
|
old_canon = normalize_profile_name(old_name)
|
||||||
validate_profile_name(new_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.")
|
raise ValueError("Cannot rename the default profile.")
|
||||||
if new_name == "default":
|
if new_canon == "default":
|
||||||
raise ValueError("Cannot rename to 'default' — it is reserved.")
|
raise ValueError("Cannot rename to 'default' — it is reserved.")
|
||||||
|
|
||||||
old_dir = get_profile_dir(old_name)
|
old_dir = get_profile_dir(old_canon)
|
||||||
new_dir = get_profile_dir(new_name)
|
new_dir = get_profile_dir(new_canon)
|
||||||
|
|
||||||
if not old_dir.is_dir():
|
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():
|
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
|
# 1. Stop gateway if running
|
||||||
if _check_gateway_running(old_dir):
|
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)
|
_stop_gateway_process(old_dir)
|
||||||
|
|
||||||
# 2. Rename directory
|
# 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}")
|
print(f"✓ Renamed {old_dir.name} → {new_dir.name}")
|
||||||
|
|
||||||
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
|
# 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
|
# 4. Update wrapper script
|
||||||
remove_wrapper_script(old_name)
|
remove_wrapper_script(old_canon)
|
||||||
collision = check_alias_collision(new_name)
|
collision = check_alias_collision(new_canon)
|
||||||
if not collision:
|
if not collision:
|
||||||
create_wrapper_script(new_name)
|
create_wrapper_script(new_canon)
|
||||||
print(f"✓ Alias updated: {new_name}")
|
print(f"✓ Alias updated: {new_canon}")
|
||||||
else:
|
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
|
# 5. Update active_profile if it pointed to old name
|
||||||
try:
|
try:
|
||||||
if get_active_profile() == old_name:
|
if get_active_profile() == old_canon:
|
||||||
set_active_profile(new_name)
|
set_active_profile(new_canon)
|
||||||
print(f"✓ Active profile updated: {new_name}")
|
print(f"✓ Active profile updated: {new_canon}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1191,13 +1228,14 @@ def resolve_profile_env(profile_name: str) -> str:
|
|||||||
Called early in the CLI entry point, before any hermes modules
|
Called early in the CLI entry point, before any hermes modules
|
||||||
are imported, to set the HERMES_HOME environment variable.
|
are imported, to set the HERMES_HOME environment variable.
|
||||||
"""
|
"""
|
||||||
validate_profile_name(profile_name)
|
canon = normalize_profile_name(profile_name)
|
||||||
profile_dir = get_profile_dir(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(
|
raise FileNotFoundError(
|
||||||
f"Profile '{profile_name}' does not exist. "
|
f"Profile '{canon}' does not exist. "
|
||||||
f"Create it with: hermes profile create {profile_name}"
|
f"Create it with: hermes profile create {canon}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return str(profile_dir)
|
return str(profile_dir)
|
||||||
|
|||||||
@@ -108,9 +108,14 @@ class PtyBridge:
|
|||||||
"(or pip install -e '.[pty]')."
|
"(or pip install -e '.[pty]')."
|
||||||
)
|
)
|
||||||
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
|
raise PtyUnavailableError("Pseudo-terminals are unavailable.")
|
||||||
# Let caller-supplied env fully override inheritance; if they pass
|
# PTY-hosted programs expect TERM to describe the terminal type.
|
||||||
# None we inherit the server's env (same semantics as subprocess).
|
# CI often runs without TERM in the parent process, which makes
|
||||||
spawn_env = os.environ.copy() if env is None else env
|
# 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]
|
proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr]
|
||||||
list(argv),
|
list(argv),
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
|
|||||||
+36
-8
@@ -964,7 +964,8 @@ def setup_model_provider(config: dict, *, quick: bool = False):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
|
_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(
|
print_success(
|
||||||
f"Vision configured with {_base_url}"
|
f"Vision configured with {_base_url}"
|
||||||
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")
|
+ (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_success("Terminal backend: Local")
|
||||||
print_info("Commands run directly on this machine.")
|
print_info("Commands run directly on this machine.")
|
||||||
|
|
||||||
# CWD for messaging
|
# Gateway/cron working directory
|
||||||
print()
|
print()
|
||||||
print_info("Working directory for messaging sessions:")
|
print_info("Gateway working directory:")
|
||||||
print_info(" When using Hermes via Telegram/Discord, this is where")
|
print_info(" Used by Telegram/Discord/cron sessions.")
|
||||||
print_info(
|
print_info(" CLI/TUI always uses your launch directory instead.")
|
||||||
" the agent starts. CLI mode always starts in the current directory."
|
|
||||||
)
|
|
||||||
current_cwd = cfg_get(config, "terminal", "cwd", default="")
|
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:
|
if cwd:
|
||||||
config["terminal"]["cwd"] = 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_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_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():
|
def _write_slack_manifest_and_instruct():
|
||||||
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
"""Generate the Slack manifest, write it under HERMES_HOME, and print
|
||||||
@@ -2995,6 +3004,21 @@ def run_setup_wizard(args):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
hermes_home = get_hermes_home()
|
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)
|
# Detect non-interactive environments (headless SSH, Docker, CI/CD)
|
||||||
non_interactive = getattr(args, 'non_interactive', False)
|
non_interactive = getattr(args, 'non_interactive', False)
|
||||||
if not non_interactive and not is_interactive_stdin():
|
if not non_interactive and not is_interactive_stdin():
|
||||||
@@ -3164,6 +3188,10 @@ def run_setup_wizard(args):
|
|||||||
|
|
||||||
# Save and show summary
|
# Save and show summary
|
||||||
save_config(config)
|
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)
|
_print_setup_summary(config, hermes_home)
|
||||||
|
|
||||||
_offer_launch_chat()
|
_offer_launch_chat()
|
||||||
|
|||||||
+25
-5
@@ -122,11 +122,16 @@ def show_status(args):
|
|||||||
print()
|
print()
|
||||||
print(color("◆ API Keys", Colors.CYAN, Colors.BOLD))
|
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",
|
"OpenRouter": "OPENROUTER_API_KEY",
|
||||||
"OpenAI": "OPENAI_API_KEY",
|
"OpenAI": "OPENAI_API_KEY",
|
||||||
"NVIDIA": "NVIDIA_API_KEY",
|
"Anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN"),
|
||||||
"Z.AI/GLM": "GLM_API_KEY",
|
"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",
|
"Kimi": "KIMI_API_KEY",
|
||||||
"StepFun Step Plan": "STEPFUN_API_KEY",
|
"StepFun Step Plan": "STEPFUN_API_KEY",
|
||||||
"MiniMax": "MINIMAX_API_KEY",
|
"MiniMax": "MINIMAX_API_KEY",
|
||||||
@@ -142,8 +147,23 @@ def show_status(args):
|
|||||||
"GitHub": "GITHUB_TOKEN",
|
"GitHub": "GITHUB_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, env_var in keys.items():
|
def _resolve_env(env_ref) -> str:
|
||||||
value = get_env_value(env_var) or ""
|
"""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)
|
has_key = bool(value)
|
||||||
display = redact_key(value) if not show_all else value
|
display = redact_key(value) if not show_all else value
|
||||||
print(f" {name:<12} {check_mark(has_key)} {display}")
|
print(f" {name:<12} {check_mark(has_key)} {display}")
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ CONFIGURABLE_TOOLSETS = [
|
|||||||
("file", "📁 File Operations", "read, write, patch, search"),
|
("file", "📁 File Operations", "read, write, patch, search"),
|
||||||
("code_execution", "⚡ Code Execution", "execute_code"),
|
("code_execution", "⚡ Code Execution", "execute_code"),
|
||||||
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
|
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
|
||||||
|
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
|
||||||
("image_gen", "🎨 Image Generation", "image_generate"),
|
("image_gen", "🎨 Image Generation", "image_generate"),
|
||||||
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
|
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
|
||||||
("tts", "🔊 Text-to-Speech", "text_to_speech"),
|
("tts", "🔊 Text-to-Speech", "text_to_speech"),
|
||||||
@@ -78,7 +79,7 @@ CONFIGURABLE_TOOLSETS = [
|
|||||||
# Toolsets that are OFF by default for new installs.
|
# Toolsets that are OFF by default for new installs.
|
||||||
# They're still in _HERMES_CORE_TOOLS (available at runtime if enabled),
|
# 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.
|
# 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
|
# Platform-scoped toolsets: only appear in the `hermes tools` checklist for
|
||||||
# these platforms, and only resolve/save for these platforms. A toolset
|
# these platforms, and only resolve/save for these platforms. A toolset
|
||||||
@@ -1919,21 +1920,27 @@ def _reconfigure_provider(provider: dict, config: dict):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if provider.get("tts_provider"):
|
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']}")
|
_print_success(f" TTS provider set to: {provider['tts_provider']}")
|
||||||
|
|
||||||
if "browser_provider" in provider:
|
if "browser_provider" in provider:
|
||||||
bp = provider["browser_provider"]
|
bp = provider["browser_provider"]
|
||||||
|
browser_cfg = config.setdefault("browser", {})
|
||||||
if bp == "local":
|
if bp == "local":
|
||||||
config.setdefault("browser", {})["cloud_provider"] = "local"
|
browser_cfg["cloud_provider"] = "local"
|
||||||
_print_success(" Browser set to local mode")
|
_print_success(" Browser set to local mode")
|
||||||
elif bp:
|
elif bp:
|
||||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
browser_cfg["cloud_provider"] = bp
|
||||||
_print_success(f" Browser cloud provider set to: {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
|
# Set web search backend in config if applicable
|
||||||
if provider.get("web_backend"):
|
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']}")
|
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||||
|
|
||||||
if managed_feature and managed_feature not in ("web", "tts", "browser"):
|
if managed_feature and managed_feature not in ("web", "tts", "browser"):
|
||||||
|
|||||||
@@ -510,10 +510,23 @@ except (ValueError, TypeError):
|
|||||||
)
|
)
|
||||||
_GATEWAY_HEALTH_TIMEOUT = 3.0
|
_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]:
|
def _probe_gateway_health() -> tuple[bool, dict | None]:
|
||||||
"""Probe the gateway via its HTTP health endpoint (cross-container).
|
"""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
|
Uses ``/health/detailed`` first (returns full state), falling back to
|
||||||
the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.
|
the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``.
|
||||||
|
|
||||||
|
|||||||
+38
-3
@@ -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"``,
|
Handles ``"type": "integer"``, ``"type": "number"``, ``"type": "boolean"``,
|
||||||
and union types (``"type": ["integer", "string"]``).
|
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):
|
if not args or not isinstance(args, dict):
|
||||||
return args
|
return args
|
||||||
@@ -523,13 +529,42 @@ def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
if not properties:
|
if not properties:
|
||||||
return args
|
return args
|
||||||
|
|
||||||
for key, value in args.items():
|
for key, value in list(args.items()):
|
||||||
if not isinstance(value, str):
|
|
||||||
continue
|
|
||||||
prop_schema = properties.get(key)
|
prop_schema = properties.get(key)
|
||||||
if not prop_schema:
|
if not prop_schema:
|
||||||
continue
|
continue
|
||||||
expected = prop_schema.get("type")
|
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):
|
if not expected and not _schema_allows_null(prop_schema):
|
||||||
continue
|
continue
|
||||||
coerced = _coerce_value(value, expected, schema=prop_schema)
|
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
|
Designs the visual language: framing, color, motion, transitions. Reviews
|
||||||
generator output for visual consistency. Hands off per-scene `VISUAL_SPEC.md`.
|
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
|
- **Skills:** `kanban-worker` plus the visual skill that matches the project
|
||||||
(e.g., `ascii-video` for ASCII work, `manim-video` for explainers,
|
(e.g., `ascii-video` for ASCII work, `manim-video` for explainers,
|
||||||
`touchdesigner-mcp` for real-time visuals, etc.)
|
`touchdesigner-mcp` for real-time visuals, etc.)
|
||||||
- **Outputs:** `scenes/scene-NN/VISUAL_SPEC.md`, review comments on renderer
|
- **Outputs:** `scenes/scene-NN/VISUAL_SPEC.md`, review comments on renderer
|
||||||
tasks
|
tasks
|
||||||
- **Reviews via:** any media-analysis approach (Gemini multimodal, manual
|
- **Reviews via:** `video_analyze` (sends full clip to multimodal LLM for
|
||||||
inspection of clip thumbnails, ffprobe summaries)
|
native review), `vision_analyze` for spot-checking frames, ffprobe summaries
|
||||||
|
|
||||||
## Production roles
|
## 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
|
quality). Distinct from the cinematographer (who reviews visuals during
|
||||||
production) and the editor (who reviews for assembly).
|
production) and the editor (who reviews for assembly).
|
||||||
|
|
||||||
- **Toolsets:** kanban, terminal, file
|
- **Toolsets:** kanban, terminal, file, video, vision
|
||||||
- **Skills:** `kanban-worker`
|
- **Skills:** `kanban-worker`
|
||||||
- **External tools:** any media-analysis approach (Gemini multimodal,
|
- **Review tools:** `video_analyze` (native clip review via multimodal LLM),
|
||||||
ffprobe, manual frame extraction)
|
`vision_analyze` (frame/thumbnail review), ffprobe
|
||||||
- **Outputs:** `review-notes.md`, comments on tasks
|
- **Outputs:** `review-notes.md`, comments on tasks
|
||||||
|
|
||||||
### brand-cop
|
### brand-cop
|
||||||
|
|||||||
@@ -81,7 +81,16 @@ them directly.
|
|||||||
| Remotion CLI (`npx remotion render`) | React-based motion graphics | renderer-motion-graphics |
|
| 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 |
|
| 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 |
|
| 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
|
## Standard toolset configurations per role
|
||||||
|
|
||||||
@@ -156,6 +165,8 @@ toolsets:
|
|||||||
- kanban
|
- kanban
|
||||||
- terminal
|
- terminal
|
||||||
- file
|
- file
|
||||||
|
- video # video_analyze — review full clips natively
|
||||||
|
- vision # vision_analyze — review stills / exported frames
|
||||||
skills:
|
skills:
|
||||||
always_load:
|
always_load:
|
||||||
- kanban-worker
|
- kanban-worker
|
||||||
@@ -246,6 +257,8 @@ toolsets:
|
|||||||
- kanban
|
- kanban
|
||||||
- terminal
|
- terminal
|
||||||
- file
|
- file
|
||||||
|
- video # video_analyze — editor reviews assembled cuts natively
|
||||||
|
- vision # vision_analyze — spot-check frames
|
||||||
skills:
|
skills:
|
||||||
always_load:
|
always_load:
|
||||||
- kanban-worker
|
- kanban-worker
|
||||||
@@ -259,14 +272,13 @@ For captioner add Whisper invocation patterns to the SOUL.md.
|
|||||||
```yaml
|
```yaml
|
||||||
toolsets:
|
toolsets:
|
||||||
- kanban
|
- kanban
|
||||||
- terminal # for media inspection
|
- terminal # for media inspection (ffprobe, etc.)
|
||||||
- file
|
- file
|
||||||
|
- video # video_analyze — review full clips natively
|
||||||
|
- vision # vision_analyze — review stills / exported frames
|
||||||
skills:
|
skills:
|
||||||
always_load:
|
always_load:
|
||||||
- kanban-worker
|
- 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
|
## 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 |
|
| ElevenLabs | `ELEVENLABS_API_KEY` | voice-talent |
|
||||||
| OpenAI | `OPENAI_API_KEY` | image-generator (DALL-E), voice-talent (TTS) |
|
| 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) |
|
| FAL | `FAL_KEY` | image-generator (FAL flux models) |
|
||||||
| Replicate | `REPLICATE_API_TOKEN` | image-generator (alternate provider) |
|
| Replicate | `REPLICATE_API_TOKEN` | image-generator (alternate provider) |
|
||||||
| Runway | `RUNWAY_API_KEY` | image-to-video-generator |
|
| Runway | `RUNWAY_API_KEY` | image-to-video-generator |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class NodeServer:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
host: str = "0.0.0.0",
|
host: str = "127.0.0.1",
|
||||||
port: int = 18789,
|
port: int = 18789,
|
||||||
token_path: Optional[Path] = None,
|
token_path: Optional[Path] = None,
|
||||||
display_name: str = "hermes-meet-node",
|
display_name: str = "hermes-meet-node",
|
||||||
@@ -76,6 +76,13 @@ class NodeServer:
|
|||||||
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
|
json.dumps({"token": tok, "generated_at": time.time()}, indent=2),
|
||||||
encoding="utf-8",
|
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)
|
tmp.replace(self.token_path)
|
||||||
self._token = tok
|
self._token = tok
|
||||||
return 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.
|
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).
|
> **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
|
## What it does
|
||||||
|
|
||||||
|
|||||||
+303
-2
@@ -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) {
|
function StatCard(props) {
|
||||||
return React.createElement(C.Card, { className: "ha-stat" },
|
return React.createElement(C.Card, { className: "ha-stat" },
|
||||||
React.createElement(C.CardContent, { className: "ha-stat-content" },
|
React.createElement(C.CardContent, { className: "ha-stat-content" },
|
||||||
@@ -170,6 +460,7 @@
|
|||||||
const targetTier = achievement.next_tier || achievement.tier;
|
const targetTier = achievement.next_tier || achievement.tier;
|
||||||
const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective")));
|
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 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)) },
|
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(C.CardContent, { className: "ha-card-content" },
|
||||||
React.createElement("div", { className: "ha-card-head" },
|
React.createElement("div", { className: "ha-card-head" },
|
||||||
@@ -180,7 +471,13 @@
|
|||||||
),
|
),
|
||||||
React.createElement("div", { className: "ha-badges" },
|
React.createElement("div", { className: "ha-badges" },
|
||||||
React.createElement("span", { className: "ha-state-badge" }, stateLabel),
|
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),
|
React.createElement("p", { className: "ha-description" }, achievement.description),
|
||||||
@@ -200,7 +497,11 @@
|
|||||||
),
|
),
|
||||||
React.createElement("span", { className: "ha-progress-text" }, progressText)
|
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-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-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; }
|
.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",
|
"label": "Achievements",
|
||||||
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
|
||||||
"icon": "Star",
|
"icon": "Star",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"tab": { "path": "/achievements", "position": "after:analytics" },
|
"tab": { "path": "/achievements", "position": "after:analytics" },
|
||||||
"entry": "dist/index.js",
|
"entry": "dist/index.js",
|
||||||
"css": "dist/style.css",
|
"css": "dist/style.css",
|
||||||
|
|||||||
@@ -203,11 +203,12 @@ class XAIImageGenProvider(ImageGenProvider):
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.HTTPError as exc:
|
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:
|
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:
|
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)
|
logger.error("xAI image gen failed (%d): %s", status, err_msg)
|
||||||
return error_response(
|
return error_response(
|
||||||
error=f"xAI image generation failed ({status}): {err_msg}",
|
error=f"xAI image generation failed ({status}): {err_msg}",
|
||||||
|
|||||||
+408
-43
@@ -63,6 +63,53 @@
|
|||||||
const API = "/api/plugins/kanban";
|
const API = "/api/plugins/kanban";
|
||||||
const MIME_TASK = "text/x-hermes-task";
|
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.
|
// Minimal safe markdown renderer.
|
||||||
//
|
//
|
||||||
@@ -245,7 +292,19 @@
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
function KanbanPage() {
|
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 [config, setConfig] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -292,9 +351,9 @@
|
|||||||
if (tenantFilter) qs.set("tenant", tenantFilter);
|
if (tenantFilter) qs.set("tenant", tenantFilter);
|
||||||
if (includeArchived) qs.set("include_archived", "true");
|
if (includeArchived) qs.set("include_archived", "true");
|
||||||
const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`;
|
const url = qs.toString() ? `${API}/board?${qs}` : `${API}/board`;
|
||||||
return SDK.fetchJSON(url)
|
return SDK.fetchJSON(withBoard(url, board))
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
setBoard(data);
|
setBoardData(data);
|
||||||
cursorRef.current = data.latest_event_id || 0;
|
cursorRef.current = data.latest_event_id || 0;
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
@@ -302,7 +361,26 @@
|
|||||||
setError(String(err && err.message ? err.message : err));
|
setError(String(err && err.message ? err.message : err));
|
||||||
})
|
})
|
||||||
.finally(function () { setLoading(false); });
|
.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 () {
|
const scheduleReload = useCallback(function () {
|
||||||
if (reloadTimerRef.current) return;
|
if (reloadTimerRef.current) return;
|
||||||
@@ -324,16 +402,21 @@
|
|||||||
|
|
||||||
// --- WebSocket ---------------------------------------------------------
|
// --- WebSocket ---------------------------------------------------------
|
||||||
useEffect(function () {
|
useEffect(function () {
|
||||||
if (!board) return undefined;
|
if (!boardData) return undefined;
|
||||||
wsClosedRef.current = false;
|
wsClosedRef.current = false;
|
||||||
function openWs() {
|
function openWs() {
|
||||||
if (wsClosedRef.current) return;
|
if (wsClosedRef.current) return;
|
||||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const qs = new URLSearchParams({
|
const qsParams = {
|
||||||
since: String(cursorRef.current || 0),
|
since: String(cursorRef.current || 0),
|
||||||
token: token,
|
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}`;
|
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
||||||
let ws;
|
let ws;
|
||||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||||
@@ -372,11 +455,11 @@
|
|||||||
wsClosedRef.current = true;
|
wsClosedRef.current = true;
|
||||||
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
|
try { wsRef.current && wsRef.current.close(); } catch (_e) { /* noop */ }
|
||||||
};
|
};
|
||||||
}, [!!board, scheduleReload]);
|
}, [!!boardData, board, scheduleReload]);
|
||||||
|
|
||||||
// --- filtering ----------------------------------------------------------
|
// --- filtering ----------------------------------------------------------
|
||||||
const filteredBoard = useMemo(function () {
|
const filteredBoard = useMemo(function () {
|
||||||
if (!board) return null;
|
if (!boardData) return null;
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
const filterTask = function (t) {
|
const filterTask = function (t) {
|
||||||
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
if (assigneeFilter && t.assignee !== assigneeFilter) return false;
|
||||||
@@ -386,18 +469,18 @@
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
return Object.assign({}, board, {
|
return Object.assign({}, boardData, {
|
||||||
columns: board.columns.map(function (col) {
|
columns: boardData.columns.map(function (col) {
|
||||||
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
return Object.assign({}, col, { tasks: col.tasks.filter(filterTask) });
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}, [board, assigneeFilter, search]);
|
}, [boardData, assigneeFilter, search]);
|
||||||
|
|
||||||
// --- actions ------------------------------------------------------------
|
// --- actions ------------------------------------------------------------
|
||||||
const moveTask = useCallback(function (taskId, newStatus) {
|
const moveTask = useCallback(function (taskId, newStatus) {
|
||||||
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
||||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
setBoard(function (b) {
|
setBoardData(function (b) {
|
||||||
if (!b) return b;
|
if (!b) return b;
|
||||||
let moved = null;
|
let moved = null;
|
||||||
const columns = b.columns.map(function (col) {
|
const columns = b.columns.map(function (col) {
|
||||||
@@ -413,7 +496,7 @@
|
|||||||
}
|
}
|
||||||
return Object.assign({}, b, { columns });
|
return Object.assign({}, b, { columns });
|
||||||
});
|
});
|
||||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, {
|
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: newStatus }),
|
||||||
@@ -421,10 +504,10 @@
|
|||||||
setError(`Move failed: ${err.message || err}`);
|
setError(`Move failed: ${err.message || err}`);
|
||||||
loadBoard();
|
loadBoard();
|
||||||
});
|
});
|
||||||
}, [loadBoard]);
|
}, [loadBoard, board]);
|
||||||
|
|
||||||
const createTask = useCallback(function (body) {
|
const createTask = useCallback(function (body) {
|
||||||
return SDK.fetchJSON(`${API}/tasks`, {
|
return SDK.fetchJSON(withBoard(`${API}/tasks`, board), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -437,9 +520,10 @@
|
|||||||
setError("Task created, but: " + res.warning);
|
setError("Task created, but: " + res.warning);
|
||||||
}
|
}
|
||||||
loadBoard();
|
loadBoard();
|
||||||
|
loadBoardList(); // refresh counts in the switcher
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
}, [loadBoard]);
|
}, [loadBoard, loadBoardList, board]);
|
||||||
|
|
||||||
const toggleSelected = useCallback(function (id, additive) {
|
const toggleSelected = useCallback(function (id, additive) {
|
||||||
setSelectedIds(function (prev) {
|
setSelectedIds(function (prev) {
|
||||||
@@ -455,7 +539,7 @@
|
|||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
|
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
|
||||||
SDK.fetchJSON(`${API}/tasks/bulk`, {
|
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -470,14 +554,50 @@
|
|||||||
loadBoard();
|
loadBoard();
|
||||||
})
|
})
|
||||||
.catch(function (e) { setError(String(e.message || e)); });
|
.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 -------------------------------------------------------------
|
// --- render -------------------------------------------------------------
|
||||||
if (loading && !board) {
|
if (loading && !boardData) {
|
||||||
return h("div", { className: "p-8 text-sm text-muted-foreground" },
|
return h("div", { className: "p-8 text-sm text-muted-foreground" },
|
||||||
"Loading Kanban board…");
|
"Loading Kanban board…");
|
||||||
}
|
}
|
||||||
if (error && !board) {
|
if (error && !boardData) {
|
||||||
return h(Card, null,
|
return h(Card, null,
|
||||||
h(CardContent, { className: "p-6" },
|
h(CardContent, { className: "p-6" },
|
||||||
h("div", { className: "text-sm text-destructive" },
|
h("div", { className: "text-sm text-destructive" },
|
||||||
@@ -493,15 +613,28 @@
|
|||||||
|
|
||||||
return h(ErrorBoundary, null,
|
return h(ErrorBoundary, null,
|
||||||
h("div", { className: "hermes-kanban flex flex-col gap-4" },
|
h("div", { className: "hermes-kanban flex flex-col gap-4" },
|
||||||
h(BoardToolbar, {
|
h(BoardSwitcher, {
|
||||||
board: board,
|
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,
|
tenantFilter, setTenantFilter,
|
||||||
assigneeFilter, setAssigneeFilter,
|
assigneeFilter, setAssigneeFilter,
|
||||||
includeArchived, setIncludeArchived,
|
includeArchived, setIncludeArchived,
|
||||||
laneByProfile, setLaneByProfile,
|
laneByProfile, setLaneByProfile,
|
||||||
search, setSearch,
|
search, setSearch,
|
||||||
onNudgeDispatch: function () {
|
onNudgeDispatch: function () {
|
||||||
SDK.fetchJSON(`${API}/dispatch?max=8`, { method: "POST" })
|
SDK.fetchJSON(withBoard(`${API}/dispatch?max=8`, board), { method: "POST" })
|
||||||
.then(loadBoard)
|
.then(loadBoard)
|
||||||
.catch(function (e) { setError(String(e.message || e)); });
|
.catch(function (e) { setError(String(e.message || e)); });
|
||||||
},
|
},
|
||||||
@@ -509,7 +642,7 @@
|
|||||||
}),
|
}),
|
||||||
selectedIds.size > 0 ? h(BulkActionBar, {
|
selectedIds.size > 0 ? h(BulkActionBar, {
|
||||||
count: selectedIds.size,
|
count: selectedIds.size,
|
||||||
assignees: (board && board.assignees) || [],
|
assignees: (boardData && boardData.assignees) || [],
|
||||||
onApply: applyBulk,
|
onApply: applyBulk,
|
||||||
onClear: clearSelected,
|
onClear: clearSelected,
|
||||||
}) : null,
|
}) : null,
|
||||||
@@ -522,20 +655,215 @@
|
|||||||
onMove: moveTask,
|
onMove: moveTask,
|
||||||
onOpen: setSelectedTaskId,
|
onOpen: setSelectedTaskId,
|
||||||
onCreate: createTask,
|
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, {
|
selectedTaskId ? h(TaskDrawer, {
|
||||||
taskId: selectedTaskId,
|
taskId: selectedTaskId,
|
||||||
|
boardSlug: board,
|
||||||
onClose: function () { setSelectedTaskId(null); },
|
onClose: function () { setSelectedTaskId(null); },
|
||||||
onRefresh: loadBoard,
|
onRefresh: loadBoard,
|
||||||
renderMarkdown: renderMd,
|
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,
|
eventTick: taskEventTick[selectedTaskId] || 0,
|
||||||
}) : null,
|
}) : 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
|
// Toolbar
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -555,11 +883,10 @@
|
|||||||
),
|
),
|
||||||
h("div", { className: "flex flex-col gap-1" },
|
h("div", { className: "flex flex-col gap-1" },
|
||||||
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
h(Label, { className: "text-xs text-muted-foreground" }, "Tenant"),
|
||||||
h(Select, {
|
h(Select, Object.assign({
|
||||||
value: props.tenantFilter,
|
value: props.tenantFilter,
|
||||||
onChange: function (e) { props.setTenantFilter(e.target.value); },
|
|
||||||
className: "h-8",
|
className: "h-8",
|
||||||
},
|
}, selectChangeHandler(props.setTenantFilter)),
|
||||||
h(SelectOption, { value: "" }, "All tenants"),
|
h(SelectOption, { value: "" }, "All tenants"),
|
||||||
tenants.map(function (t) {
|
tenants.map(function (t) {
|
||||||
return h(SelectOption, { key: t, value: t }, t);
|
return h(SelectOption, { key: t, value: t }, t);
|
||||||
@@ -568,11 +895,10 @@
|
|||||||
),
|
),
|
||||||
h("div", { className: "flex flex-col gap-1" },
|
h("div", { className: "flex flex-col gap-1" },
|
||||||
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
h(Label, { className: "text-xs text-muted-foreground" }, "Assignee"),
|
||||||
h(Select, {
|
h(Select, Object.assign({
|
||||||
value: props.assigneeFilter,
|
value: props.assigneeFilter,
|
||||||
onChange: function (e) { props.setAssigneeFilter(e.target.value); },
|
|
||||||
className: "h-8",
|
className: "h-8",
|
||||||
},
|
}, selectChangeHandler(props.setAssigneeFilter)),
|
||||||
h(SelectOption, { value: "" }, "All profiles"),
|
h(SelectOption, { value: "" }, "All profiles"),
|
||||||
assignees.map(function (a) {
|
assignees.map(function (a) {
|
||||||
return h(SelectOption, { key: a, value: a }, a);
|
return h(SelectOption, { key: a, value: a }, a);
|
||||||
@@ -919,6 +1245,12 @@
|
|||||||
const [priority, setPriority] = useState(0);
|
const [priority, setPriority] = useState(0);
|
||||||
const [parent, setParent] = useState("");
|
const [parent, setParent] = useState("");
|
||||||
const [skills, setSkills] = 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 submit = function () {
|
||||||
const trimmed = title.trim();
|
const trimmed = title.trim();
|
||||||
@@ -938,10 +1270,23 @@
|
|||||||
.map(function (s) { return s.trim(); })
|
.map(function (s) { return s.trim(); })
|
||||||
.filter(function (s) { return s.length > 0; });
|
.filter(function (s) { return s.length > 0; });
|
||||||
if (skillList.length > 0) body.skills = skillList;
|
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);
|
props.onSubmit(body);
|
||||||
setTitle(""); setAssignee(""); setPriority(0); setParent(""); setSkills("");
|
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" },
|
return h("div", { className: "hermes-kanban-inline-create" },
|
||||||
h(Input, {
|
h(Input, {
|
||||||
value: title,
|
value: title,
|
||||||
@@ -978,6 +1323,24 @@
|
|||||||
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
title: "Force-load these skills into the worker (in addition to the built-in kanban-worker).",
|
||||||
className: "h-7 text-xs",
|
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, {
|
h(Select, {
|
||||||
value: parent,
|
value: parent,
|
||||||
onChange: function (e) { setParent(e.target.value); },
|
onChange: function (e) { setParent(e.target.value); },
|
||||||
@@ -1012,13 +1375,14 @@
|
|||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [newComment, setNewComment] = useState("");
|
const [newComment, setNewComment] = useState("");
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const boardSlug = props.boardSlug;
|
||||||
|
|
||||||
const load = useCallback(function () {
|
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); })
|
.then(function (d) { setData(d); setErr(null); })
|
||||||
.catch(function (e) { setErr(String(e.message || e)); })
|
.catch(function (e) { setErr(String(e.message || e)); })
|
||||||
.finally(function () { setLoading(false); });
|
.finally(function () { setLoading(false); });
|
||||||
}, [props.taskId]);
|
}, [props.taskId, boardSlug]);
|
||||||
|
|
||||||
// Reload when the WS stream reports new events for this task id
|
// Reload when the WS stream reports new events for this task id
|
||||||
// (completion, block, crash, etc. — anything that'd make the drawer
|
// (completion, block, crash, etc. — anything that'd make the drawer
|
||||||
@@ -1033,7 +1397,7 @@
|
|||||||
const handleComment = function () {
|
const handleComment = function () {
|
||||||
const body = newComment.trim();
|
const body = newComment.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, {
|
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/comments`, boardSlug), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ body }),
|
body: JSON.stringify({ body }),
|
||||||
@@ -1048,7 +1412,7 @@
|
|||||||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
return SDK.fetchJSON(`${API}/tasks/${encodeURIComponent(props.taskId)}`, {
|
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
@@ -1056,7 +1420,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addLink = function (parentId) {
|
const addLink = function (parentId) {
|
||||||
return SDK.fetchJSON(`${API}/links`, {
|
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
|
body: JSON.stringify({ parent_id: parentId, child_id: props.taskId }),
|
||||||
@@ -1065,12 +1429,12 @@
|
|||||||
};
|
};
|
||||||
const removeLink = function (parentId) {
|
const removeLink = function (parentId) {
|
||||||
const qs = new URLSearchParams({ parent_id: parentId, child_id: props.taskId });
|
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(); })
|
.then(function () { load(); props.onRefresh(); })
|
||||||
.catch(function (e) { setErr(String(e.message || e)); });
|
.catch(function (e) { setErr(String(e.message || e)); });
|
||||||
};
|
};
|
||||||
const addChild = function (childId) {
|
const addChild = function (childId) {
|
||||||
return SDK.fetchJSON(`${API}/links`, {
|
return SDK.fetchJSON(withBoard(`${API}/links`, boardSlug), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
|
body: JSON.stringify({ parent_id: props.taskId, child_id: childId }),
|
||||||
@@ -1079,7 +1443,7 @@
|
|||||||
};
|
};
|
||||||
const removeChild = function (childId) {
|
const removeChild = function (childId) {
|
||||||
const qs = new URLSearchParams({ parent_id: props.taskId, child_id: 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(); })
|
.then(function () { load(); props.onRefresh(); })
|
||||||
.catch(function (e) { setErr(String(e.message || e)); });
|
.catch(function (e) { setErr(String(e.message || e)); });
|
||||||
};
|
};
|
||||||
@@ -1104,6 +1468,7 @@
|
|||||||
data, editing, setEditing,
|
data, editing, setEditing,
|
||||||
renderMarkdown: props.renderMarkdown,
|
renderMarkdown: props.renderMarkdown,
|
||||||
allTasks: props.allTasks,
|
allTasks: props.allTasks,
|
||||||
|
boardSlug: boardSlug,
|
||||||
onPatch: doPatch,
|
onPatch: doPatch,
|
||||||
onAddParent: addLink,
|
onAddParent: addLink,
|
||||||
onRemoveParent: removeLink,
|
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 || [] }),
|
h(RunHistorySection, { runs: props.data.runs || [] }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1287,10 +1652,10 @@
|
|||||||
const [state, setState] = useState({ loading: false, data: null, err: null });
|
const [state, setState] = useState({ loading: false, data: null, err: null });
|
||||||
const load = useCallback(function () {
|
const load = useCallback(function () {
|
||||||
setState({ loading: true, data: null, err: null });
|
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 }); })
|
.then(function (d) { setState({ loading: false, data: d, err: null }); })
|
||||||
.catch(function (e) { setState({ loading: false, data: null, err: String(e.message || e) }); });
|
.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
|
// Auto-load when the section mounts; the user opened the drawer so the
|
||||||
// cost is one small HTTP round-trip.
|
// cost is one small HTTP round-trip.
|
||||||
|
|||||||
+79
-14
@@ -268,7 +268,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hermes-kanban-drawer {
|
.hermes-kanban-drawer {
|
||||||
width: min(480px, 92vw);
|
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--color-border);
|
||||||
@@ -334,7 +334,7 @@
|
|||||||
.hermes-kanban-meta-row {
|
.hermes-kanban-meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.hermes-kanban-meta-label {
|
.hermes-kanban-meta-label {
|
||||||
width: 92px;
|
width: 92px;
|
||||||
@@ -367,14 +367,15 @@
|
|||||||
|
|
||||||
.hermes-kanban-pre {
|
.hermes-kanban-pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.45rem 0.55rem;
|
padding: 0.5rem 0.6rem;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
|
background: color-mix(in srgb, var(--color-foreground) 4%, transparent);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
font-size: 0.72rem;
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,8 +606,8 @@
|
|||||||
/* ---- Markdown rendering -------------------------------------------- */
|
/* ---- Markdown rendering -------------------------------------------- */
|
||||||
|
|
||||||
.hermes-kanban-md {
|
.hermes-kanban-md {
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
.hermes-kanban-md p { margin: 0.25rem 0; }
|
.hermes-kanban-md p { margin: 0.25rem 0; }
|
||||||
@@ -632,15 +633,22 @@
|
|||||||
}
|
}
|
||||||
.hermes-kanban-md code {
|
.hermes-kanban-md code {
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
|
||||||
border-radius: 3px;
|
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 {
|
.hermes-kanban-md-code {
|
||||||
margin: 0.35rem 0;
|
margin: 0.35rem 0;
|
||||||
padding: 0.5rem 0.6rem;
|
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: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -648,8 +656,9 @@
|
|||||||
.hermes-kanban-md-code code {
|
.hermes-kanban-md-code code {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.75rem;
|
font-size: 0.8rem;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
.hermes-kanban-md strong { font-weight: 600; }
|
.hermes-kanban-md strong { font-weight: 600; }
|
||||||
|
|
||||||
@@ -684,11 +693,11 @@
|
|||||||
/* ---- Worker log pane ------------------------------------------------ */
|
/* ---- Worker log pane ------------------------------------------------ */
|
||||||
|
|
||||||
.hermes-kanban-log {
|
.hermes-kanban-log {
|
||||||
max-height: 340px;
|
max-height: 360px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-size: 0.7rem;
|
font-size: 0.78rem;
|
||||||
line-height: 1.45;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -739,7 +748,8 @@
|
|||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
}
|
}
|
||||||
.hermes-kanban-run-summary {
|
.hermes-kanban-run-summary {
|
||||||
font-size: 0.75rem;
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
padding: 0.2rem 0 0;
|
padding: 0.2rem 0 0;
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
@@ -751,10 +761,65 @@
|
|||||||
}
|
}
|
||||||
.hermes-kanban-run-meta {
|
.hermes-kanban-run-meta {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.65rem;
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
padding: 0.15rem 0 0;
|
padding: 0.15rem 0 0;
|
||||||
color: var(--color-muted-foreground);
|
color: var(--color-muted-foreground);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
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))
|
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.
|
"""Open a kanban_db connection, creating the schema on first use.
|
||||||
|
|
||||||
Every handler that mutates the DB goes through this so the plugin
|
Every handler that mutates the DB goes through this so the plugin
|
||||||
self-heals on a fresh install (no user-visible "no such table"
|
self-heals on a fresh install (no user-visible "no such table"
|
||||||
error if somebody hits POST /tasks before GET /board).
|
error if somebody hits POST /tasks before GET /board).
|
||||||
``init_db`` is idempotent.
|
``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:
|
try:
|
||||||
kanban_db.init_db()
|
kanban_db.init_db(board=board)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("kanban init_db failed: %s", 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(
|
def get_board(
|
||||||
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
|
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
|
||||||
include_archived: bool = Query(False),
|
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.
|
"""Return the full board grouped by status column.
|
||||||
|
|
||||||
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
|
``_conn()`` auto-initializes ``kanban.db`` on first call so a fresh
|
||||||
install doesn't surface a "failed to load" error on the plugin tab.
|
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:
|
try:
|
||||||
tasks = kanban_db.list_tasks(
|
tasks = kanban_db.list_tasks(
|
||||||
conn, tenant=tenant, include_archived=include_archived
|
conn, tenant=tenant, include_archived=include_archived
|
||||||
@@ -274,8 +306,9 @@ def get_board(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}")
|
@router.get("/tasks/{task_id}")
|
||||||
def get_task(task_id: str):
|
def get_task(task_id: str, board: Optional[str] = Query(None)):
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
task = kanban_db.get_task(conn, task_id)
|
task = kanban_db.get_task(conn, task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
@@ -311,8 +344,9 @@ class CreateTaskBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks")
|
@router.post("/tasks")
|
||||||
def create_task(payload: CreateTaskBody):
|
def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
task_id = kanban_db.create_task(
|
task_id = kanban_db.create_task(
|
||||||
conn,
|
conn,
|
||||||
@@ -373,8 +407,9 @@ class UpdateTaskBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/tasks/{task_id}")
|
@router.patch("/tasks/{task_id}")
|
||||||
def update_task(task_id: str, payload: UpdateTaskBody):
|
def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Query(None)):
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
task = kanban_db.get_task(conn, task_id)
|
task = kanban_db.get_task(conn, task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
@@ -414,7 +449,12 @@ def update_task(task_id: str, payload: UpdateTaskBody):
|
|||||||
ok = _set_status_direct(conn, task_id, "ready")
|
ok = _set_status_direct(conn, task_id, "ready")
|
||||||
elif s == "archived":
|
elif s == "archived":
|
||||||
ok = kanban_db.archive_task(conn, task_id)
|
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)
|
ok = _set_status_direct(conn, task_id, s)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
|
raise HTTPException(status_code=400, detail=f"unknown status: {s}")
|
||||||
@@ -527,10 +567,11 @@ class CommentBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/comments")
|
@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():
|
if not payload.body.strip():
|
||||||
raise HTTPException(status_code=400, detail="body is required")
|
raise HTTPException(status_code=400, detail="body is required")
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
if kanban_db.get_task(conn, task_id) is None:
|
if kanban_db.get_task(conn, task_id) is None:
|
||||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||||
@@ -552,8 +593,9 @@ class LinkBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/links")
|
@router.post("/links")
|
||||||
def add_link(payload: LinkBody):
|
def add_link(payload: LinkBody, board: Optional[str] = Query(None)):
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
|
kanban_db.link_tasks(conn, payload.parent_id, payload.child_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -564,8 +606,13 @@ def add_link(payload: LinkBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/links")
|
@router.delete("/links")
|
||||||
def delete_link(parent_id: str = Query(...), child_id: str = Query(...)):
|
def delete_link(
|
||||||
conn = _conn()
|
parent_id: str = Query(...),
|
||||||
|
child_id: str = Query(...),
|
||||||
|
board: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
|
ok = kanban_db.unlink_tasks(conn, parent_id, child_id)
|
||||||
return {"ok": bool(ok)}
|
return {"ok": bool(ok)}
|
||||||
@@ -586,7 +633,7 @@ class BulkTaskBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/bulk")
|
@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``.
|
"""Apply the same patch to every id in ``payload.ids``.
|
||||||
|
|
||||||
This is an *independent* iteration — per-task failures don't abort
|
This is an *independent* iteration — per-task failures don't abort
|
||||||
@@ -596,7 +643,8 @@ def bulk_update(payload: BulkTaskBody):
|
|||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(status_code=400, detail="ids is required")
|
raise HTTPException(status_code=400, detail="ids is required")
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
for tid in ids:
|
for tid in ids:
|
||||||
entry: dict[str, Any] = {"id": tid, "ok": True}
|
entry: dict[str, Any] = {"id": tid, "ok": True}
|
||||||
@@ -690,14 +738,15 @@ def get_config():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
def get_stats():
|
def get_stats(board: Optional[str] = Query(None)):
|
||||||
"""Per-status + per-assignee counts + oldest-ready age.
|
"""Per-status + per-assignee counts + oldest-ready age.
|
||||||
|
|
||||||
Designed for the dashboard HUD and for router profiles that need to
|
Designed for the dashboard HUD and for router profiles that need to
|
||||||
answer "is this specialist overloaded?" without scanning the whole
|
answer "is this specialist overloaded?" without scanning the whole
|
||||||
board themselves.
|
board themselves.
|
||||||
"""
|
"""
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
return kanban_db.board_stats(conn)
|
return kanban_db.board_stats(conn)
|
||||||
finally:
|
finally:
|
||||||
@@ -705,7 +754,7 @@ def get_stats():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/assignees")
|
@router.get("/assignees")
|
||||||
def get_assignees():
|
def get_assignees(board: Optional[str] = Query(None)):
|
||||||
"""Known profiles + per-profile task counts.
|
"""Known profiles + per-profile task counts.
|
||||||
|
|
||||||
Returns the union of ``~/.hermes/profiles/*`` on disk and every
|
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
|
this to populate its assignee dropdown so a freshly-created profile
|
||||||
appears in the picker before it's been given any task.
|
appears in the picker before it's been given any task.
|
||||||
"""
|
"""
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
return {"assignees": kanban_db.known_assignees(conn)}
|
return {"assignees": kanban_db.known_assignees(conn)}
|
||||||
finally:
|
finally:
|
||||||
@@ -725,7 +775,11 @@ def get_assignees():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/log")
|
@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.
|
"""Return the worker's stdout/stderr log.
|
||||||
|
|
||||||
``tail`` caps the response size (bytes) so the dashboard drawer
|
``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
|
``_rotate_worker_log`` — a single ``.log.1`` is kept, no further
|
||||||
generations, so disk usage per task is bounded at ~4 MiB.
|
generations, so disk usage per task is bounded at ~4 MiB.
|
||||||
"""
|
"""
|
||||||
conn = _conn()
|
board = _resolve_board(board)
|
||||||
|
conn = _conn(board=board)
|
||||||
try:
|
try:
|
||||||
task = kanban_db.get_task(conn, task_id)
|
task = kanban_db.get_task(conn, task_id)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||||
content = kanban_db.read_worker_log(task_id, tail_bytes=tail)
|
content = kanban_db.read_worker_log(task_id, tail_bytes=tail, board=board)
|
||||||
log_path = kanban_db.worker_log_path(task_id)
|
log_path = kanban_db.worker_log_path(task_id, board=board)
|
||||||
size = log_path.stat().st_size if log_path.exists() else 0
|
size = log_path.stat().st_size if log_path.exists() else 0
|
||||||
return {
|
return {
|
||||||
"task_id": task_id,
|
"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")
|
@router.post("/dispatch")
|
||||||
def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
def dispatch(
|
||||||
conn = _conn()
|
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:
|
try:
|
||||||
result = kanban_db.dispatch_once(
|
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.
|
# DispatchResult is a dataclass.
|
||||||
try:
|
try:
|
||||||
@@ -775,6 +835,124 @@ def dispatch(dry_run: bool = Query(False), max_n: int = Query(8, alias="max")):
|
|||||||
conn.close()
|
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>
|
# WebSocket: /events?since=<event_id>
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -802,8 +980,18 @@ async def stream_events(ws: WebSocket):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
cursor = 0
|
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]]:
|
def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
|
||||||
conn = kanban_db.connect()
|
conn = kanban_db.connect(board=ws_board)
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, task_id, run_id, kind, payload, created_at "
|
"SELECT id, task_id, run_id, kind, payload, created_at "
|
||||||
|
|||||||
@@ -592,6 +592,8 @@ def interactive_setup() -> None:
|
|||||||
from hermes_cli.config import (
|
from hermes_cli.config import (
|
||||||
get_env_value,
|
get_env_value,
|
||||||
save_env_value,
|
save_env_value,
|
||||||
|
)
|
||||||
|
from hermes_cli.cli_output import (
|
||||||
prompt,
|
prompt,
|
||||||
prompt_yes_no,
|
prompt_yes_no,
|
||||||
print_info,
|
print_info,
|
||||||
|
|||||||
+84
-7
@@ -3611,7 +3611,7 @@ class AIAgent:
|
|||||||
_parent_runtime = self._current_main_runtime()
|
_parent_runtime = self._current_main_runtime()
|
||||||
review_agent = AIAgent(
|
review_agent = AIAgent(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
max_iterations=8,
|
max_iterations=16,
|
||||||
quiet_mode=True,
|
quiet_mode=True,
|
||||||
platform=self.platform,
|
platform=self.platform,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
@@ -3629,6 +3629,14 @@ class AIAgent:
|
|||||||
review_agent._user_profile_enabled = self._user_profile_enabled
|
review_agent._user_profile_enabled = self._user_profile_enabled
|
||||||
review_agent._memory_nudge_interval = 0
|
review_agent._memory_nudge_interval = 0
|
||||||
review_agent._skill_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(
|
review_agent.run_conversation(
|
||||||
user_message=prompt,
|
user_message=prompt,
|
||||||
@@ -5056,6 +5064,23 @@ class AIAgent:
|
|||||||
return tc.get("call_id", "") or tc.get("id", "") or ""
|
return tc.get("call_id", "") or tc.get("id", "") or ""
|
||||||
return getattr(tc, "call_id", "") or getattr(tc, "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"})
|
_VALID_API_ROLES = frozenset({"system", "user", "assistant", "tool", "function", "developer"})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -5118,6 +5143,7 @@ class AIAgent:
|
|||||||
if cid in missing_results:
|
if cid in missing_results:
|
||||||
patched.append({
|
patched.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": AIAgent._get_tool_call_name_static(tc),
|
||||||
"content": "[Result unavailable — see context summary above]",
|
"content": "[Result unavailable — see context summary above]",
|
||||||
"tool_call_id": cid,
|
"tool_call_id": cid,
|
||||||
})
|
})
|
||||||
@@ -5816,6 +5842,17 @@ class AIAgent:
|
|||||||
return primary_client
|
return primary_client
|
||||||
with self._openai_client_lock():
|
with self._openai_client_lock():
|
||||||
request_kwargs = dict(self._client_kwargs)
|
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 (
|
if (
|
||||||
base_url_host_matches(str(request_kwargs.get("base_url", "")), "api.githubcopilot.com")
|
base_url_host_matches(str(request_kwargs.get("base_url", "")), "api.githubcopilot.com")
|
||||||
and self._api_kwargs_have_image_parts(api_kwargs or {})
|
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.
|
"""True when using an anthropic-compatible endpoint that preserves dots in model names.
|
||||||
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
|
Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).
|
||||||
MiniMax keeps dots (e.g. MiniMax-M2.7).
|
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).
|
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).
|
ZAI/Zhipu keeps dots (e.g. glm-4.7, glm-5.1).
|
||||||
AWS Bedrock uses dotted inference-profile IDs
|
AWS Bedrock uses dotted inference-profile IDs
|
||||||
@@ -8205,6 +8243,7 @@ class AIAgent:
|
|||||||
"alibaba", "minimax", "minimax-cn",
|
"alibaba", "minimax", "minimax-cn",
|
||||||
"opencode-go", "opencode-zen",
|
"opencode-go", "opencode-zen",
|
||||||
"zai", "bedrock",
|
"zai", "bedrock",
|
||||||
|
"xiaomi",
|
||||||
}:
|
}:
|
||||||
return True
|
return True
|
||||||
base = (getattr(self, "base_url", "") or "").lower()
|
base = (getattr(self, "base_url", "") or "").lower()
|
||||||
@@ -8214,6 +8253,7 @@ class AIAgent:
|
|||||||
or "minimax" in base
|
or "minimax" in base
|
||||||
or "opencode.ai/zen/" in base
|
or "opencode.ai/zen/" in base
|
||||||
or "bigmodel.cn" in base
|
or "bigmodel.cn" in base
|
||||||
|
or "xiaomimimo.com" in base
|
||||||
# AWS Bedrock runtime endpoints — defense-in-depth when
|
# AWS Bedrock runtime endpoints — defense-in-depth when
|
||||||
# ``provider`` is unset but ``base_url`` still names Bedrock.
|
# ``provider`` is unset but ``base_url`` still names Bedrock.
|
||||||
or "bedrock-runtime." in base
|
or "bedrock-runtime." in base
|
||||||
@@ -9008,6 +9048,7 @@ class AIAgent:
|
|||||||
insert_at,
|
insert_at,
|
||||||
{
|
{
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": function_name if function_name != "?" else "",
|
||||||
"tool_call_id": tool_call_id,
|
"tool_call_id": tool_call_id,
|
||||||
"content": marker,
|
"content": marker,
|
||||||
},
|
},
|
||||||
@@ -9412,6 +9453,7 @@ class AIAgent:
|
|||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": tc.function.name,
|
||||||
"content": f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
"content": f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||||
"tool_call_id": tc.id,
|
"tool_call_id": tc.id,
|
||||||
})
|
})
|
||||||
@@ -9753,6 +9795,7 @@ class AIAgent:
|
|||||||
|
|
||||||
tool_msg = {
|
tool_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": name,
|
||||||
"content": function_result,
|
"content": function_result,
|
||||||
"tool_call_id": tc.id,
|
"tool_call_id": tc.id,
|
||||||
}
|
}
|
||||||
@@ -9790,6 +9833,7 @@ class AIAgent:
|
|||||||
skipped_name = skipped_tc.function.name
|
skipped_name = skipped_tc.function.name
|
||||||
skip_msg = {
|
skip_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": skipped_name,
|
||||||
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||||
"tool_call_id": skipped_tc.id,
|
"tool_call_id": skipped_tc.id,
|
||||||
}
|
}
|
||||||
@@ -10140,6 +10184,7 @@ class AIAgent:
|
|||||||
|
|
||||||
tool_msg = {
|
tool_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": function_name,
|
||||||
"content": function_result,
|
"content": function_result,
|
||||||
"tool_call_id": tool_call.id
|
"tool_call_id": tool_call.id
|
||||||
}
|
}
|
||||||
@@ -10166,6 +10211,7 @@ class AIAgent:
|
|||||||
skipped_name = skipped_tc.function.name
|
skipped_name = skipped_tc.function.name
|
||||||
skip_msg = {
|
skip_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": skipped_name,
|
||||||
"content": f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
"content": f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||||
"tool_call_id": skipped_tc.id
|
"tool_call_id": skipped_tc.id
|
||||||
}
|
}
|
||||||
@@ -10300,7 +10346,10 @@ class AIAgent:
|
|||||||
provider_preferences["order"] = self.providers_order
|
provider_preferences["order"] = self.providers_order
|
||||||
if self.provider_sort:
|
if self.provider_sort:
|
||||||
provider_preferences["sort"] = 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
|
summary_extra_body["provider"] = provider_preferences
|
||||||
|
|
||||||
if summary_extra_body:
|
if summary_extra_body:
|
||||||
@@ -10418,6 +10467,15 @@ class AIAgent:
|
|||||||
from hermes_logging import set_session_context
|
from hermes_logging import set_session_context
|
||||||
set_session_context(self.session_id)
|
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
|
# If the previous turn activated fallback, restore the primary
|
||||||
# runtime so this turn gets a fresh attempt with the preferred model.
|
# runtime so this turn gets a fresh attempt with the preferred model.
|
||||||
# No-op when _fallback_activated is False (gateway, first turn, etc.).
|
# No-op when _fallback_activated is False (gateway, first turn, etc.).
|
||||||
@@ -10623,11 +10681,11 @@ class AIAgent:
|
|||||||
self.model,
|
self.model,
|
||||||
f"{self.context_compressor.context_length:,}",
|
f"{self.context_compressor.context_length:,}",
|
||||||
)
|
)
|
||||||
if not self.quiet_mode:
|
self._emit_status(
|
||||||
self._safe_print(
|
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
f">= {self.context_compressor.threshold_tokens:,} threshold. "
|
||||||
f">= {self.context_compressor.threshold_tokens:,} threshold"
|
"This may take a moment."
|
||||||
)
|
)
|
||||||
# May need multiple passes for very large sessions with small
|
# May need multiple passes for very large sessions with small
|
||||||
# context windows (each pass summarises the middle N turns).
|
# context windows (each pass summarises the middle N turns).
|
||||||
for _pass in range(3):
|
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."
|
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": tc.function.name,
|
||||||
"tool_call_id": tc.id,
|
"tool_call_id": tc.id,
|
||||||
"content": content,
|
"content": content,
|
||||||
})
|
})
|
||||||
@@ -13167,6 +13226,7 @@ class AIAgent:
|
|||||||
tool_result = "Skipped: other tool call in this response had invalid JSON."
|
tool_result = "Skipped: other tool call in this response had invalid JSON."
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": tc.function.name,
|
||||||
"tool_call_id": tc.id,
|
"tool_call_id": tc.id,
|
||||||
"content": tool_result,
|
"content": tool_result,
|
||||||
})
|
})
|
||||||
@@ -13415,9 +13475,22 @@ class AIAgent:
|
|||||||
m.get("role") == "tool"
|
m.get("role") == "tool"
|
||||||
for m in messages[-5:] # check recent messages
|
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 (
|
if (
|
||||||
_prior_was_tool
|
_prior_was_tool
|
||||||
and not getattr(self, "_post_tool_empty_retried", False)
|
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
|
self._post_tool_empty_retried = True
|
||||||
# Clear stale narration so it doesn't resurface
|
# Clear stale narration so it doesn't resurface
|
||||||
@@ -13457,10 +13530,13 @@ class AIAgent:
|
|||||||
# continue — the model will see its own reasoning
|
# continue — the model will see its own reasoning
|
||||||
# on the next turn and produce the text portion.
|
# on the next turn and produce the text portion.
|
||||||
# Inspired by clawdbot's "incomplete-text" recovery.
|
# Inspired by clawdbot's "incomplete-text" recovery.
|
||||||
|
# Also covers Qwen3/Ollama in-content <think> blocks
|
||||||
|
# (detected above as _has_inline_thinking).
|
||||||
_has_structured = bool(
|
_has_structured = bool(
|
||||||
getattr(assistant_message, "reasoning", None)
|
getattr(assistant_message, "reasoning", None)
|
||||||
or getattr(assistant_message, "reasoning_content", None)
|
or getattr(assistant_message, "reasoning_content", None)
|
||||||
or getattr(assistant_message, "reasoning_details", None)
|
or getattr(assistant_message, "reasoning_details", None)
|
||||||
|
or _has_inline_thinking
|
||||||
)
|
)
|
||||||
if _has_structured and self._thinking_prefill_retries < 2:
|
if _has_structured and self._thinking_prefill_retries < 2:
|
||||||
self._thinking_prefill_retries += 1
|
self._thinking_prefill_retries += 1
|
||||||
@@ -13667,6 +13743,7 @@ class AIAgent:
|
|||||||
if tc["id"] not in answered_ids:
|
if tc["id"] not in answered_ids:
|
||||||
err_msg = {
|
err_msg = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
|
"name": AIAgent._get_tool_call_name_static(tc),
|
||||||
"tool_call_id": tc["id"],
|
"tool_call_id": tc["id"],
|
||||||
"content": f"Error executing tool: {error_msg}",
|
"content": f"Error executing tool: {error_msg}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ AUTHOR_MAP = {
|
|||||||
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
"127238744+teknium1@users.noreply.github.com": "teknium1",
|
||||||
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
|
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
|
||||||
"aludwin+gh@gmail.com": "adamludwin",
|
"aludwin+gh@gmail.com": "adamludwin",
|
||||||
|
"ngusev@astralinux.ru": "NikolayGusev-astra",
|
||||||
"2093036+exiao@users.noreply.github.com": "exiao",
|
"2093036+exiao@users.noreply.github.com": "exiao",
|
||||||
"rylen.anil@gmail.com": "rylena",
|
"rylen.anil@gmail.com": "rylena",
|
||||||
"godnanijatin@gmail.com": "jatingodnani",
|
"godnanijatin@gmail.com": "jatingodnani",
|
||||||
@@ -67,6 +68,8 @@ AUTHOR_MAP = {
|
|||||||
"nbot@liizfq.top": "liizfq",
|
"nbot@liizfq.top": "liizfq",
|
||||||
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
|
||||||
"dejie.guo@gmail.com": "JayGwod",
|
"dejie.guo@gmail.com": "JayGwod",
|
||||||
|
"133716830+0xKingBack@users.noreply.github.com": "0xKingBack",
|
||||||
|
"daixin1204@gmail.com": "SimbaKingjoe",
|
||||||
"maxence@groine.fr": "MaxyMoos",
|
"maxence@groine.fr": "MaxyMoos",
|
||||||
"61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
|
"61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
|
||||||
# OpenViking viking_read salvage (April 2026)
|
# OpenViking viking_read salvage (April 2026)
|
||||||
@@ -95,6 +98,7 @@ AUTHOR_MAP = {
|
|||||||
"252818347@qq.com": "hejuntt1014",
|
"252818347@qq.com": "hejuntt1014",
|
||||||
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
|
||||||
"beliefanx@gmail.com": "BeliefanX",
|
"beliefanx@gmail.com": "BeliefanX",
|
||||||
|
"changchun989@proton.me": "changchun989",
|
||||||
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
"jefferson@heimdallstrategy.com": "Mind-Dragon",
|
||||||
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
|
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
|
||||||
"steve.westerhouse@origami-analytics.com": "westers",
|
"steve.westerhouse@origami-analytics.com": "westers",
|
||||||
@@ -339,6 +343,8 @@ AUTHOR_MAP = {
|
|||||||
"haileymarshall005@gmail.com": "haileymarshall",
|
"haileymarshall005@gmail.com": "haileymarshall",
|
||||||
"greer.guthrie@gmail.com": "g-guthrie",
|
"greer.guthrie@gmail.com": "g-guthrie",
|
||||||
"kennyx102@gmail.com": "bobashopcashier",
|
"kennyx102@gmail.com": "bobashopcashier",
|
||||||
|
"77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
|
||||||
|
"25355950+megastary@users.noreply.github.com": "megastary", # PR #18325
|
||||||
"shokatalishaikh95@gmail.com": "areu01or00",
|
"shokatalishaikh95@gmail.com": "areu01or00",
|
||||||
"bryan@intertwinesys.com": "bryanyoung",
|
"bryan@intertwinesys.com": "bryanyoung",
|
||||||
"christo.mitov@gmail.com": "christomitov",
|
"christo.mitov@gmail.com": "christomitov",
|
||||||
@@ -457,6 +463,7 @@ AUTHOR_MAP = {
|
|||||||
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
"centripetal-star@users.noreply.github.com": "centripetal-star",
|
||||||
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||||
"154585401+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",
|
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
|
||||||
"niyant@spicefi.xyz": "spniyant",
|
"niyant@spicefi.xyz": "spniyant",
|
||||||
"olafthiele@gmail.com": "olafthiele",
|
"olafthiele@gmail.com": "olafthiele",
|
||||||
@@ -510,6 +517,7 @@ AUTHOR_MAP = {
|
|||||||
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
|
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
|
||||||
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
|
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
|
||||||
"hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed)
|
"hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed)
|
||||||
|
"exx@example.com": "exxmen", # PR #19555
|
||||||
"hypnosis.mda@gmail.com": "Hypn0sis",
|
"hypnosis.mda@gmail.com": "Hypn0sis",
|
||||||
"ywt000818@gmail.com": "OwenYWT",
|
"ywt000818@gmail.com": "OwenYWT",
|
||||||
"dhandhalyabhavik@gmail.com": "v1k22",
|
"dhandhalyabhavik@gmail.com": "v1k22",
|
||||||
@@ -621,6 +629,84 @@ AUTHOR_MAP = {
|
|||||||
"2114364329@qq.com": "cuyua9",
|
"2114364329@qq.com": "cuyua9",
|
||||||
"2557058999@qq.com": "Disaster-Terminator",
|
"2557058999@qq.com": "Disaster-Terminator",
|
||||||
"cine.dreamer.one@gmail.com": "LeonSGP43",
|
"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",
|
"leozeli@qq.com": "leozeli",
|
||||||
"linlehao@cuhk.edu.cn": "LehaoLin",
|
"linlehao@cuhk.edu.cn": "LehaoLin",
|
||||||
"liutong@isacas.ac.cn": "I3eg1nner",
|
"liutong@isacas.ac.cn": "I3eg1nner",
|
||||||
@@ -679,6 +765,8 @@ AUTHOR_MAP = {
|
|||||||
"ztzheng@163.com": "chengoak", # PR #17467
|
"ztzheng@163.com": "chengoak", # PR #17467
|
||||||
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
|
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
|
||||||
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
|
"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-opus-4-6") is False
|
||||||
assert _forbids_sampling_params("claude-sonnet-4-5") 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):
|
def test_reasoning_disabled(self):
|
||||||
kwargs = build_anthropic_kwargs(
|
kwargs = build_anthropic_kwargs(
|
||||||
model="claude-sonnet-4-20250514",
|
model="claude-sonnet-4-20250514",
|
||||||
|
|||||||
@@ -1893,3 +1893,53 @@ class TestOpenRouterExplicitApiKey:
|
|||||||
assert call_kwargs["api_key"] == "env-fallback-key", (
|
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']}"
|
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."""
|
"""Classifier that decides whether an exception warrants client eviction."""
|
||||||
|
|
||||||
def test_detects_botocore_connection_closed_error(self):
|
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 agent.bedrock_adapter import is_stale_connection_error
|
||||||
from botocore.exceptions import ConnectionClosedError
|
from botocore.exceptions import ConnectionClosedError
|
||||||
exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
|
exc = ConnectionClosedError(endpoint_url="https://bedrock.example")
|
||||||
assert is_stale_connection_error(exc) is True
|
assert is_stale_connection_error(exc) is True
|
||||||
|
|
||||||
def test_detects_botocore_endpoint_connection_error(self):
|
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 agent.bedrock_adapter import is_stale_connection_error
|
||||||
from botocore.exceptions import EndpointConnectionError
|
from botocore.exceptions import EndpointConnectionError
|
||||||
exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
|
exc = EndpointConnectionError(endpoint_url="https://bedrock.example")
|
||||||
assert is_stale_connection_error(exc) is True
|
assert is_stale_connection_error(exc) is True
|
||||||
|
|
||||||
def test_detects_botocore_read_timeout(self):
|
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 agent.bedrock_adapter import is_stale_connection_error
|
||||||
from botocore.exceptions import ReadTimeoutError
|
from botocore.exceptions import ReadTimeoutError
|
||||||
exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
|
exc = ReadTimeoutError(endpoint_url="https://bedrock.example")
|
||||||
@@ -1355,6 +1358,7 @@ class TestCallConverseInvalidatesOnStaleError:
|
|||||||
reconnects instead of reusing the dead socket."""
|
reconnects instead of reusing the dead socket."""
|
||||||
|
|
||||||
def test_converse_evicts_client_on_stale_error(self):
|
def test_converse_evicts_client_on_stale_error(self):
|
||||||
|
pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests")
|
||||||
from agent.bedrock_adapter import (
|
from agent.bedrock_adapter import (
|
||||||
_bedrock_runtime_client_cache,
|
_bedrock_runtime_client_cache,
|
||||||
call_converse,
|
call_converse,
|
||||||
@@ -1381,6 +1385,7 @@ class TestCallConverseInvalidatesOnStaleError:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_converse_stream_evicts_client_on_stale_error(self):
|
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 (
|
from agent.bedrock_adapter import (
|
||||||
_bedrock_runtime_client_cache,
|
_bedrock_runtime_client_cache,
|
||||||
call_converse_stream,
|
call_converse_stream,
|
||||||
@@ -1406,6 +1411,7 @@ class TestCallConverseInvalidatesOnStaleError:
|
|||||||
|
|
||||||
def test_converse_does_not_evict_on_non_stale_error(self):
|
def test_converse_does_not_evict_on_non_stale_error(self):
|
||||||
"""Non-stale errors (e.g. ValidationException) leave the client cache alone."""
|
"""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 (
|
from agent.bedrock_adapter import (
|
||||||
_bedrock_runtime_client_cache,
|
_bedrock_runtime_client_cache,
|
||||||
call_converse,
|
call_converse,
|
||||||
|
|||||||
@@ -1281,6 +1281,47 @@ class TestTokenBudgetTailProtection:
|
|||||||
assert isinstance(cut, int)
|
assert isinstance(cut, int)
|
||||||
assert 0 <= cut <= len(messages)
|
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:
|
class TestUpdateModelBudgets:
|
||||||
"""Regression: update_model() must recalculate token budgets."""
|
"""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()
|
long_ago = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
|
||||||
data = u.load_usage()
|
data = u.load_usage()
|
||||||
data["old-skill"] = u._empty_record()
|
data["old-skill"] = u._empty_record()
|
||||||
|
data["old-skill"]["created_by"] = "agent"
|
||||||
data["old-skill"]["last_used_at"] = long_ago
|
data["old-skill"]["last_used_at"] = long_ago
|
||||||
data["old-skill"]["created_at"] = long_ago
|
data["old-skill"]["created_at"] = long_ago
|
||||||
u.save_usage(data)
|
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()
|
super_old = (datetime.now(timezone.utc) - timedelta(days=120)).isoformat()
|
||||||
data = u.load_usage()
|
data = u.load_usage()
|
||||||
data["ancient"] = u._empty_record()
|
data["ancient"] = u._empty_record()
|
||||||
|
data["ancient"]["created_by"] = "agent"
|
||||||
data["ancient"]["last_used_at"] = super_old
|
data["ancient"]["last_used_at"] = super_old
|
||||||
data["ancient"]["created_at"] = super_old
|
data["ancient"]["created_at"] = super_old
|
||||||
u.save_usage(data)
|
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()
|
super_old = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat()
|
||||||
data = u.load_usage()
|
data = u.load_usage()
|
||||||
data["precious"] = u._empty_record()
|
data["precious"] = u._empty_record()
|
||||||
|
data["precious"]["created_by"] = "agent"
|
||||||
data["precious"]["last_used_at"] = super_old
|
data["precious"]["last_used_at"] = super_old
|
||||||
data["precious"]["created_at"] = super_old
|
data["precious"]["created_at"] = super_old
|
||||||
data["precious"]["pinned"] = True
|
data["precious"]["pinned"] = True
|
||||||
@@ -214,6 +217,7 @@ def test_stale_skill_reactivates_on_recent_use(curator_env):
|
|||||||
recent = datetime.now(timezone.utc).isoformat()
|
recent = datetime.now(timezone.utc).isoformat()
|
||||||
data = u.load_usage()
|
data = u.load_usage()
|
||||||
data["revived"] = u._empty_record()
|
data["revived"] = u._empty_record()
|
||||||
|
data["revived"]["created_by"] = "agent"
|
||||||
data["revived"]["state"] = "stale"
|
data["revived"]["state"] = "stale"
|
||||||
data["revived"]["last_used_at"] = recent
|
data["revived"]["last_used_at"] = recent
|
||||||
data["revived"]["created_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()
|
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):
|
def test_bundled_skill_not_touched_by_transitions(curator_env):
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
u = curator_env["usage"]
|
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):
|
def test_run_review_records_state(curator_env):
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
|
|
||||||
result = c.run_curator_review(synchronous=True)
|
result = c.run_curator_review(synchronous=True)
|
||||||
assert "started_at" in result
|
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.
|
`hermes curator status`. Fixes #18373.
|
||||||
"""
|
"""
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
|
|
||||||
# Stub the LLM so the test doesn't need a provider.
|
# Stub the LLM so the test doesn't need a provider.
|
||||||
monkeypatch.setattr(
|
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
|
skips automatic transitions — but the LLM prompt is the only guard
|
||||||
against the model calling skill_manage directly."""
|
against the model calling skill_manage directly."""
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
def _stub(prompt):
|
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
|
archives skills deterministically, and a preview must not touch the
|
||||||
filesystem."""
|
filesystem."""
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
|
|
||||||
called = {"n": 0}
|
called = {"n": 0}
|
||||||
def _explode(*_a, **_kw):
|
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):
|
def test_run_review_synchronous_invokes_llm_stub(curator_env, monkeypatch):
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
def _stub(prompt):
|
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):
|
def test_maybe_run_curator_runs_when_eligible(curator_env, monkeypatch):
|
||||||
c = curator_env["curator"]
|
c = curator_env["curator"]
|
||||||
|
u = curator_env["usage"]
|
||||||
skills_dir = curator_env["home"] / "skills"
|
skills_dir = curator_env["home"] / "skills"
|
||||||
_write_skill(skills_dir, "a")
|
_write_skill(skills_dir, "a")
|
||||||
|
u.mark_agent_created("a")
|
||||||
# Seed last_run_at far in the past so the interval gate opens — the
|
# Seed last_run_at far in the past so the interval gate opens — the
|
||||||
# "no state" path intentionally defers the first run now (#18373).
|
# "no state" path intentionally defers the first run now (#18373).
|
||||||
long_ago = datetime.now(timezone.utc) - timedelta(hours=c.get_interval_hours() * 2)
|
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):
|
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.
|
"""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
|
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):
|
def test_report_md_splits_consolidated_and_pruned_sections(curator_env):
|
||||||
"""End-to-end: REPORT.md shows both sections distinctly."""
|
"""End-to-end: REPORT.md shows both sections distinctly."""
|
||||||
curator = curator_env
|
curator = curator_env
|
||||||
|
|||||||
@@ -410,6 +410,24 @@ class TestClassifyApiError:
|
|||||||
result = classify_api_error(e, approx_tokens=1000, context_length=200000)
|
result = classify_api_error(e, approx_tokens=1000, context_length=200000)
|
||||||
assert result.reason == FailoverReason.format_error
|
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 ──
|
# ── Server disconnect + large session ──
|
||||||
|
|
||||||
def test_disconnect_large_session_context_overflow(self):
|
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)
|
result = classify_api_error(e, approx_tokens=5000, context_length=200000)
|
||||||
assert result.reason == FailoverReason.timeout
|
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 ──
|
# ── Provider-specific: Anthropic thinking signature ──
|
||||||
|
|
||||||
def test_anthropic_thinking_signature(self):
|
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),
|
usage=MagicMock(prompt_tokens=10, completion_tokens=5),
|
||||||
)
|
)
|
||||||
|
|
||||||
with (
|
with patch(
|
||||||
patch(
|
"agent.auxiliary_client._resolve_task_provider_model",
|
||||||
"agent.auxiliary_client._resolve_task_provider_model",
|
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
|
||||||
return_value=("my-resolved-provider", "my-resolved-model", "http://resolved", "resolved-key", "chat_completions"),
|
), patch(
|
||||||
),
|
"agent.auxiliary_client.resolve_vision_provider_client",
|
||||||
patch(
|
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
|
||||||
"agent.auxiliary_client.resolve_vision_provider_client",
|
) as mock_vision:
|
||||||
return_value=("my-resolved-provider", fake_client, "my-resolved-model"),
|
|
||||||
) as mock_vision,
|
|
||||||
):
|
|
||||||
call_llm(
|
call_llm(
|
||||||
"vision",
|
"vision",
|
||||||
provider="raw-provider",
|
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["model"] == "my-resolved-model"
|
||||||
assert call_args.kwargs["base_url"] == "http://resolved"
|
assert call_args.kwargs["base_url"] == "http://resolved"
|
||||||
assert call_args.kwargs["api_key"] == "resolved-key"
|
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"
|
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):
|
def test_minimal_effort_clamped(self, transport):
|
||||||
messages = [{"role": "user", "content": "Hi"}]
|
messages = [{"role": "user", "content": "Hi"}]
|
||||||
kw = transport.build_kwargs(
|
kw = transport.build_kwargs(
|
||||||
|
|||||||
@@ -123,6 +123,13 @@ class TestBusyInputMode:
|
|||||||
cli.process_command("/queue follow up")
|
cli.process_command("/queue follow up")
|
||||||
assert cli._pending_input.get_nowait() == "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):
|
def test_queue_mode_routes_busy_enter_to_pending(self):
|
||||||
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
|
"""In queue mode, Enter while busy should go to _pending_input, not _interrupt_queue."""
|
||||||
cli = _make_cli(config_overrides={"display": {"busy_input_mode": "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
|
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():
|
def test_final_assistant_content_strips_ansi_before_markdown_rendering():
|
||||||
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
renderable = _render_final_assistant_content("\x1b[31m# Title\x1b[0m")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from hermes_state import SessionDB
|
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.last_total_tokens == 0
|
||||||
assert comp.compression_count == 0
|
assert comp.compression_count == 0
|
||||||
assert comp._context_probed is False
|
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
|
Rules:
|
||||||
imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must
|
- Local backend CLI/TUI: always os.getcwd(), ignoring config and inherited env.
|
||||||
not overwrite the already-resolved value with os.getcwd().
|
- Non-local with placeholder: pop cwd for backend default.
|
||||||
|
- Non-local with explicit path: keep as-is.
|
||||||
config.yaml terminal.cwd is the canonical source of truth.
|
|
||||||
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
|
|
||||||
See issue #10817.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
# The sentinel values that mean "resolve at runtime"
|
|
||||||
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
|
def _resolve_cwd(terminal_config: dict, defaults: dict, env: dict):
|
||||||
"""Simulate the CWD resolution logic from load_cli_config().
|
"""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
|
if effective_backend == "local":
|
||||||
TERMINAL_CWD before falling back to os.getcwd().
|
terminal_config["cwd"] = "/fake/getcwd"
|
||||||
"""
|
defaults["terminal"]["cwd"] = terminal_config["cwd"]
|
||||||
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
|
||||||
_existing_cwd = env.get("TERMINAL_CWD", "")
|
terminal_config.pop("cwd", None)
|
||||||
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)
|
|
||||||
|
|
||||||
# Simulate the bridging loop: write terminal_config["cwd"] to env
|
# Bridge: TERMINAL_CWD always exported in CLI, skipped in gateway
|
||||||
_file_has_terminal = defaults.get("_file_has_terminal", False)
|
_is_gateway = env.get("_HERMES_GATEWAY") == "1"
|
||||||
if "cwd" in terminal_config:
|
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"])
|
env["TERMINAL_CWD"] = str(terminal_config["cwd"])
|
||||||
|
|
||||||
return env.get("TERMINAL_CWD", "")
|
return env.get("TERMINAL_CWD", "")
|
||||||
|
|
||||||
|
|
||||||
class TestLazyImportGuard:
|
class TestLocalBackendCli:
|
||||||
"""TERMINAL_CWD resolved by gateway must survive a lazy cli.py import."""
|
"""Local backend always uses os.getcwd()."""
|
||||||
|
|
||||||
def test_gateway_resolved_cwd_survives(self):
|
def test_explicit_config_ignored(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()."""
|
|
||||||
env = {}
|
env = {}
|
||||||
terminal_config = {"cwd": "."}
|
tc = {"cwd": "/explicit/path", "env_type": "local"}
|
||||||
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}
|
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"
|
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"
|
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
||||||
|
|
||||||
def test_all_anthropic_models_supported(self):
|
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
|
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 = [
|
supported = [
|
||||||
"claude-opus-4-7", "claude-opus-4-6", "claude-opus-4.6",
|
"claude-opus-4-6", "claude-opus-4.6",
|
||||||
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
|
"anthropic/claude-opus-4-6", "anthropic/claude-opus-4.6",
|
||||||
"claude-haiku-4-5", "claude-3-5-haiku",
|
|
||||||
]
|
]
|
||||||
for model in supported:
|
for model in supported:
|
||||||
assert model_supports_fast_mode(model), f"{model} should support fast mode"
|
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):
|
def test_codex_models_excluded(self):
|
||||||
"""Codex models route through Responses API and don't accept service_tier."""
|
"""Codex models route through Responses API and don't accept service_tier."""
|
||||||
from hermes_cli.models import model_supports_fast_mode
|
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
|
||||||
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
|
from hermes_cli.models import model_supports_fast_mode
|
||||||
|
|
||||||
# All Claude models support fast mode — Opus, Sonnet, Haiku.
|
assert model_supports_fast_mode("claude-sonnet-4-6") is False
|
||||||
# The anthropic adapter gates speed=fast on native Anthropic
|
assert model_supports_fast_mode("claude-sonnet-4.6") is False
|
||||||
# endpoints only, so third-party proxies that reject the beta
|
assert model_supports_fast_mode("claude-haiku-4-5") is False
|
||||||
# are protected downstream (see _is_third_party_anthropic_endpoint).
|
assert model_supports_fast_mode("claude-opus-4-7") is False
|
||||||
assert model_supports_fast_mode("claude-sonnet-4-6") is True
|
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
|
||||||
assert model_supports_fast_mode("claude-sonnet-4.6") is True
|
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
|
||||||
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
|
|
||||||
|
|
||||||
def test_non_claude_models_not_anthropic_fast(self):
|
def test_non_claude_models_not_anthropic_fast(self):
|
||||||
"""Non-Claude models should not be treated as Anthropic fast-mode."""
|
"""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")
|
result = resolve_fast_mode_overrides("anthropic/claude-opus-4.6")
|
||||||
assert result == {"speed": "fast"}
|
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):
|
def test_resolve_overrides_returns_service_tier_for_openai(self):
|
||||||
"""OpenAI models should still get service_tier, not speed."""
|
"""OpenAI models should still get service_tier, not speed."""
|
||||||
from hermes_cli.models import resolve_fast_mode_overrides
|
from hermes_cli.models import resolve_fast_mode_overrides
|
||||||
@@ -302,13 +332,21 @@ class TestAnthropicFastMode(unittest.TestCase):
|
|||||||
assert result == {"service_tier": "priority"}
|
assert result == {"service_tier": "priority"}
|
||||||
|
|
||||||
def test_is_anthropic_fast_model(self):
|
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
|
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-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("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("gpt-5.4") is False
|
||||||
assert _is_anthropic_fast_model("") 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
|
assert cli_mod.HermesCLI._fast_command_available(stub) is True
|
||||||
|
|
||||||
def test_fast_command_exposed_for_anthropic_sonnet(self):
|
def test_fast_command_hidden_for_anthropic_sonnet(self):
|
||||||
"""Sonnet now supports Anthropic Fast Mode — the adapter gates on base_url."""
|
"""Sonnet doesn't support fast mode (Opus 4.6 only) — /fast must be hidden."""
|
||||||
cli_mod = _import_cli()
|
cli_mod = _import_cli()
|
||||||
stub = SimpleNamespace(
|
stub = SimpleNamespace(
|
||||||
provider="anthropic", requested_provider="anthropic",
|
provider="anthropic", requested_provider="anthropic",
|
||||||
model="claude-sonnet-4-6", agent=None,
|
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):
|
def test_fast_command_hidden_for_non_claude_non_openai(self):
|
||||||
"""Non-Claude, non-OpenAI models should not expose /fast."""
|
"""Non-Claude, non-OpenAI models should not expose /fast."""
|
||||||
|
|||||||
@@ -647,6 +647,74 @@ class TestGetDueJobs:
|
|||||||
assert get_due_jobs() == []
|
assert get_due_jobs() == []
|
||||||
assert get_job("oneshot-stale")["next_run_at"] is None
|
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:
|
class TestEnabledToolsets:
|
||||||
def test_enabled_toolsets_stored(self, tmp_cron_dir):
|
def test_enabled_toolsets_stored(self, tmp_cron_dir):
|
||||||
|
|||||||
@@ -1857,6 +1857,54 @@ class TestBuildJobPromptMissingSkill:
|
|||||||
assert "go" in result
|
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:
|
class TestSendMediaViaAdapter:
|
||||||
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
|
"""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]
|
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()
|
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:
|
class TestSessionLifecycle:
|
||||||
"""Verify session state changes across command sequences."""
|
"""Verify session state changes across command sequences."""
|
||||||
|
|||||||
@@ -240,6 +240,48 @@ class TestAdapterInit:
|
|||||||
"http://127.0.0.1:3000",
|
"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
|
# Auth checking
|
||||||
|
|||||||
@@ -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("follow_redirects") is True
|
||||||
assert kwargs.get("event_hooks", {}).get("response") == [_ssrf_redirect_guard]
|
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
|
# _strip_at_mention
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ async def test_resume_clears_session_scoped_approval_and_yolo_state():
|
|||||||
runner, session_key = _make_resume_runner()
|
runner, session_key = _make_resume_runner()
|
||||||
other_key = "agent:main:telegram:dm:other-chat"
|
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(session_key, "recursive delete")
|
||||||
approve_session(other_key, "recursive delete")
|
approve_session(other_key, "recursive delete")
|
||||||
enable_session_yolo(session_key)
|
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 is_session_yolo_enabled(session_key) is False
|
||||||
assert session_key not in runner._pending_approvals
|
assert session_key not in runner._pending_approvals
|
||||||
assert session_key not in runner._update_prompt_pending
|
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_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
assert other_key in runner._update_prompt_pending
|
assert other_key in runner._update_prompt_pending
|
||||||
|
assert other_key in runner._pending_skills_reload_notes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -151,6 +157,10 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
|||||||
runner, session_key = _make_branch_runner()
|
runner, session_key = _make_branch_runner()
|
||||||
other_key = "agent:main:telegram:dm:other-chat"
|
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(session_key, "recursive delete")
|
||||||
approve_session(other_key, "recursive delete")
|
approve_session(other_key, "recursive delete")
|
||||||
enable_session_yolo(session_key)
|
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 is_session_yolo_enabled(session_key) is False
|
||||||
assert session_key not in runner._pending_approvals
|
assert session_key not in runner._pending_approvals
|
||||||
assert session_key not in runner._update_prompt_pending
|
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_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
assert other_key in runner._update_prompt_pending
|
assert other_key in runner._update_prompt_pending
|
||||||
|
assert other_key in runner._pending_skills_reload_notes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -216,6 +228,7 @@ def test_clear_session_boundary_security_state_is_scoped():
|
|||||||
runner = object.__new__(GatewayRunner)
|
runner = object.__new__(GatewayRunner)
|
||||||
runner._pending_approvals = {}
|
runner._pending_approvals = {}
|
||||||
runner._update_prompt_pending = {}
|
runner._update_prompt_pending = {}
|
||||||
|
runner._pending_skills_reload_notes = {}
|
||||||
|
|
||||||
source = _make_source()
|
source = _make_source()
|
||||||
session_key = build_session_key(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._pending_approvals[other_key] = {"command": "rm -rf /tmp/other"}
|
||||||
runner._update_prompt_pending[session_key] = True
|
runner._update_prompt_pending[session_key] = True
|
||||||
runner._update_prompt_pending[other_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)
|
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 is_session_yolo_enabled(session_key) is False
|
||||||
assert session_key not in runner._pending_approvals
|
assert session_key not in runner._pending_approvals
|
||||||
assert session_key not in runner._update_prompt_pending
|
assert session_key not in runner._update_prompt_pending
|
||||||
|
assert session_key not in runner._pending_skills_reload_notes
|
||||||
# Other session untouched
|
# Other session untouched
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
assert is_session_yolo_enabled(other_key) is True
|
assert is_session_yolo_enabled(other_key) is True
|
||||||
assert other_key in runner._pending_approvals
|
assert other_key in runner._pending_approvals
|
||||||
assert other_key in runner._update_prompt_pending
|
assert other_key in runner._update_prompt_pending
|
||||||
|
assert other_key in runner._pending_skills_reload_notes
|
||||||
|
|
||||||
# Empty session_key is a no-op
|
# Empty session_key is a no-op
|
||||||
runner._clear_session_boundary_security_state("")
|
runner._clear_session_boundary_security_state("")
|
||||||
assert is_approved(other_key, "recursive delete") is True
|
assert is_approved(other_key, "recursive delete") is True
|
||||||
assert other_key in runner._update_prompt_pending
|
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():
|
def test_clear_session_boundary_security_state_wakes_blocked_approvals():
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ class TestSmsRequirements:
|
|||||||
class TestWebhookHostConfig:
|
class TestWebhookHostConfig:
|
||||||
"""Verify SMS_WEBHOOK_HOST env var and default."""
|
"""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
|
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):
|
def test_host_from_env(self):
|
||||||
from gateway.platforms.sms import SmsAdapter
|
from gateway.platforms.sms import SmsAdapter
|
||||||
@@ -242,6 +242,48 @@ class TestStartupGuard:
|
|||||||
result = await adapter.connect()
|
result = await adapter.connect()
|
||||||
assert result is False
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_insecure_flag_allows_start_without_url(self):
|
async def test_insecure_flag_allows_start_without_url(self):
|
||||||
mock_session = AsyncMock()
|
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:
|
class TestTeamsConnect:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_fails_without_sdk(self, monkeypatch):
|
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
|
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):
|
def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path):
|
||||||
hermes_home = tmp_path / ".hermes"
|
hermes_home = tmp_path / ".hermes"
|
||||||
hermes_home.mkdir()
|
hermes_home.mkdir()
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ across all gateway messenger platforms.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import MagicMock, patch
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from gateway.config import Platform
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||||
from gateway.platforms.base import MessageEvent
|
from gateway.platforms.base import MessageEvent
|
||||||
from gateway.session import SessionSource
|
from gateway.session import SessionSource
|
||||||
|
|
||||||
@@ -206,3 +207,152 @@ class TestTitleInHelp:
|
|||||||
import inspect
|
import inspect
|
||||||
source = inspect.getsource(GatewayRunner._handle_message)
|
source = inspect.getsource(GatewayRunner._handle_message)
|
||||||
assert '"title"' in source
|
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 "Test transcript" in msg
|
||||||
assert "42" in msg # user_id in mention
|
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 --
|
# -- _get_guild_id --
|
||||||
|
|
||||||
def test_get_guild_id_from_guild(self, runner):
|
def test_get_guild_id_from_guild(self, runner):
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ class TestWeComRequirements:
|
|||||||
|
|
||||||
|
|
||||||
class TestWeComAdapterInit:
|
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):
|
def test_reads_config_from_extra(self):
|
||||||
from gateway.platforms.wecom import WeComAdapter
|
from gateway.platforms.wecom import WeComAdapter
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 PlatformConfig
|
||||||
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
|
from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides
|
||||||
@@ -788,3 +788,43 @@ class TestIsStaleSessionRet:
|
|||||||
def test_success_codes_are_not_stale(self):
|
def test_success_codes_are_not_stale(self):
|
||||||
assert weixin._is_stale_session_ret(0, 0, "") is False
|
assert weixin._is_stale_session_ret(0, 0, "") is False
|
||||||
assert weixin._is_stale_session_ret(None, None, "unknown error") 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)
|
assert "Refresh session has been revoked" in str(exc_info.value)
|
||||||
# Must not have been rewritten with the reuse message.
|
# Must not have been rewritten with the reuse message.
|
||||||
assert "external process" not in str(exc_info.value).lower()
|
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):
|
with pytest.raises(SystemExit):
|
||||||
run_import(args)
|
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
|
# Round-trip test
|
||||||
@@ -1348,6 +1374,53 @@ class TestPreUpdateBackup:
|
|||||||
from hermes_cli.backup import create_pre_update_backup
|
from hermes_cli.backup import create_pre_update_backup
|
||||||
assert create_pre_update_backup(hermes_home=tmp_path / "does-not-exist") is None
|
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:
|
class TestRunPreUpdateBackup:
|
||||||
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
|
"""Tests for the ``_run_pre_update_backup`` wrapper in main.py —
|
||||||
|
|||||||
@@ -236,6 +236,13 @@ class TestTelegramBotCommands:
|
|||||||
tg_name = cmd.name.replace("-", "_")
|
tg_name = cmd.name.replace("-", "_")
|
||||||
assert tg_name not in names
|
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:
|
class TestSlackSubcommandMap:
|
||||||
def test_returns_dict(self):
|
def test_returns_dict(self):
|
||||||
@@ -1661,6 +1668,19 @@ class TestPluginCommandEnumeration:
|
|||||||
names = {name for name, _desc in telegram_bot_commands()}
|
names = {name for name, _desc in telegram_bot_commands()}
|
||||||
assert "metricas" in names
|
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):
|
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
|
||||||
"""/hermes metricas must route through the Slack subcommand map."""
|
"""/hermes metricas must route through the Slack subcommand map."""
|
||||||
self._patch_plugin_commands(monkeypatch, {
|
self._patch_plugin_commands(monkeypatch, {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user