Merge upstream/main into OpenViking setup-UX (salvage #32445)

Resolves conflicts from the OpenViking churn that merged after #32445 was
opened (#48042/#47662 session-switch + write hardening, #47311/#47973):

- plugins/memory/openviking/__init__.py: keep both __init__ field groups
  (the PR's _runtime_start_* alongside main's _prefetch_threads/_shutting_down).
- tests/plugins/memory/test_openviking_provider.py: keep BOTH the PR's new
  setup-validation tests and main's session-switch/concurrency tests (disjoint
  additions to the same region).

Two fixes layered while reconciling (contributor work otherwise preserved):

- Restore the merged tenant-header contract (#22414/#21232). The PR had changed
  _VikingClient defaults to '' and made empty account/user OMIT the tenant
  headers; main's contract is that empty falls back to 'default' and the
  X-OpenViking-Account/User headers are ALWAYS sent (ROOT API keys need them).
  Reverted the constructor to 'account or os.environ.get(..., "default")' and
  updated the two PR tests that asserted the omit-when-empty behavior.

- Close a secret-file TOCTOU in the setup writers. _write_env_vars and
  _write_ovcli_config wrote the api_key/root_api_key file and chmod 0600
  AFTERWARD, leaving a world-readable window on newly-created files. Added
  _precreate_secret_file() to create with 0600 before any secret bytes land.
This commit is contained in:
kshitijk4poor 2026-06-18 11:28:18 +05:30
commit 1153b42b24
248 changed files with 16037 additions and 1929 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
*.pyc*
__pycache__/
.venv/
.venv
.vscode/
.env
.env.local

View file

@ -9,8 +9,11 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df228
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
FROM debian:13.4
# Disable Python stdout buffering to ensure logs are printed immediately
# Disable Python stdout buffering to ensure logs are printed immediately.
# Do not write .pyc files at runtime: /opt/hermes is immutable in the
# published container and writable state belongs under /opt/data.
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Store Playwright browsers outside the volume mount so the build-time
# install survives the /opt/data volume overlay at runtime.
@ -186,36 +189,38 @@ RUN cd web && npm run build && \
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
COPY . .
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
# node_modules trees additionally need to be writable by the hermes user
# so the runtime `npm install` triggered by _tui_need_npm_install() in
# hermes_cli/main.py succeeds (see #18800). /opt/hermes/web is build-time
# only (HERMES_WEB_DIST points at hermes_cli/web_dist) and is intentionally
# not chowned here.
# /opt/hermes/gateway is runtime-writable: Python may create __pycache__ and
# gateway state artifacts beneath the package after services drop privileges,
# especially when the hermes UID is remapped at boot (#27221).
# The .venv MUST remain hermes-writable so lazy_deps.py can install
# remaining optional platform packages and future pin bumps at first use.
# Without this, `uv pip install` fails with EACCES and adapters silently
# fail to load. See tools/lazy_deps.py.
# Link hermes-agent itself (editable). Deps are already installed in the
# cached layer above; `--no-deps` makes this a fast egg-link creation with no
# resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# Keep /opt/hermes immutable for the runtime hermes user. Hosted/container
# instances must not be able to self-edit the installed source or venv; user
# data, skills, plugins, config, logs, and dashboard uploads live under
# /opt/data instead. Root can still repair the image during build/boot, but
# supervised Hermes processes drop to the non-root hermes user.
USER root
RUN chmod -R a+rX /opt/hermes && \
chown -R hermes:hermes /opt/hermes/.venv /opt/hermes/ui-tui /opt/hermes/gateway /opt/hermes/node_modules
RUN mkdir -p /opt/hermes/bin && \
cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
chmod 0755 /opt/hermes/bin/hermes && \
printf 'docker\n' > /opt/hermes/.install_method && \
chown -R root:root /opt/hermes && \
chmod -R a+rX /opt/hermes && \
chmod -R a-w /opt/hermes
# The ``.install_method`` stamp is baked next to the running code (the install
# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
# volume that is commonly bind-mounted from the host and even shared with a
# host-side Desktop/CLI install; stamping it at boot used to clobber that
# host install's marker and wrongly block its ``hermes update``. A code-scoped
# stamp is read first by detect_install_method() and is immune to the share.
# Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
# the data volume. Each supervised service then drops to the hermes user via
# `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
# run as the default hermes user (UID 10000).
# ---------- Link hermes-agent itself (editable) ----------
# Deps are already installed in the cached layer above; `--no-deps` makes
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
@ -235,8 +240,9 @@ RUN uv pip install --no-cache-dir --no-deps -e "."
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
chmod u+w /opt/hermes && \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
chmod a-w /opt/hermes /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
@ -282,6 +288,8 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
# check. (A separate launcher hardening is tracked independently.)
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the
@ -294,7 +302,6 @@ ENV HERMES_HOME=/opt/data
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the

View file

@ -27,7 +27,7 @@ import threading
import time
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse
from agent.context_compressor import ContextCompressor
@ -195,6 +195,7 @@ def init_agent(
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
@ -426,6 +427,7 @@ def init_agent(
agent.status_callback = status_callback
agent.notice_callback = notice_callback
agent.notice_clear_callback = notice_clear_callback
agent.event_callback = event_callback
agent.tool_gen_callback = tool_gen_callback
@ -597,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the

View file

@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
_MCP_TOOL_PREFIX = "mcp_"
_MCP_TOOL_PREFIX = "mcp__"
def _get_claude_code_version() -> str:
@ -2349,25 +2349,46 @@ def build_anthropic_kwargs(
text = text.replace("Nous Research", "Anthropic")
block["text"] = text
# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
# billing classifier treats a single-underscore ``mcp_`` tool name as
# a third-party-app fingerprint and rejects the request with HTTP 400
# "Third-party apps now draw from extra usage, not plan limits"
# (verified empirically: a single ``mcp_foo`` tool flips a request
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
#
# Two cases, both must land on the double-underscore ``mcp__`` form:
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
# b) native MCP server tools registered under their full
# single-underscore ``mcp_<server>_<tool>`` name
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
# left open: those tools were *skipped* and stayed single-underscore,
# so any session with an MCP server configured still tripped the
# classifier. normalize_response reverses both forms via registry
# lookup so the dispatcher still sees the original name. GH-25255.
def _to_oauth_wire_name(name: str) -> str:
if name.startswith("mcp__"):
return name # already correct, don't double-prefix
if name.startswith("mcp_"):
# single-underscore native MCP tool -> promote to double
return "mcp__" + name[len("mcp_"):]
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
if "name" in tool:
tool["name"] = _to_oauth_wire_name(tool["name"])
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
# 4. Apply the same normalization to tool names in message history
# (tool_use blocks) so replayed turns match the wire names above.
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
block["name"] = _to_oauth_wire_name(block["name"])
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name

View file

@ -262,6 +262,26 @@ def _responses_tools(tools: Optional[List[Dict[str, Any]]] = None) -> Optional[L
return converted or None
# Provider-executed built-in tool *declaration* types accepted on the
# Responses ``tools`` array. These are declared by ``type`` alone (no
# client-side name/parameters schema) and run server-side — the provider
# owns the implementation and reports progress via the matching ``*_call``
# output items. Hermes injects xAI's native ``web_search`` for the xAI
# transport (see agent/transports/codex.py); the rest are listed so the
# preflight validator passes them through rather than rejecting them as
# "unsupported type". Mirrors the ``*_call`` item-type set used in
# _normalize_codex_response.
_RESPONSES_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_preview",
"file_search",
"code_interpreter",
"image_generation",
"computer_use_preview",
"local_shell",
}
# ---------------------------------------------------------------------------
# Message format conversion
# ---------------------------------------------------------------------------
@ -802,7 +822,22 @@ def _preflight_codex_api_kwargs(
for idx, tool in enumerate(tools):
if not isinstance(tool, dict):
raise ValueError(f"Codex Responses tools[{idx}] must be an object.")
if tool.get("type") != "function":
tool_type = tool.get("type")
# Provider-executed built-in tools (xAI native web_search, code
# interpreter, etc.) are declared by ``type`` alone and carry no
# ``name``/``parameters`` schema — the provider owns the
# implementation. Pass them through verbatim instead of forcing
# them through the function-tool validation below (which would
# otherwise reject them with "unsupported type"). See
# agent/transports/codex.py for where xAI's native web_search is
# injected.
if tool_type in _RESPONSES_BUILTIN_TOOL_TYPES:
normalized_tools.append(dict(tool))
continue
if tool_type != "function":
raise ValueError(f"Codex Responses tools[{idx}] has unsupported type {tool.get('type')!r}.")
name = tool.get("name")
@ -1086,6 +1121,33 @@ def _normalize_codex_response(
saw_final_answer_phase = False
saw_reasoning_item = False
# Server-side built-in tool calls (xAI's native web_search, code
# interpreter, etc.) are executed by the provider and reported as
# discrete ``*_call`` output items. xAI's /v1/responses surface
# (e.g. grok-composer-2.5-fast on SuperGrok OAuth) routinely leaves
# these items at ``status="in_progress"`` even when the overall
# ``response.status == "completed"`` — the search ran to completion
# server-side, the per-item status simply isn't reconciled. These
# are NOT a signal that the model's turn is unfinished, so they must
# not flip ``has_incomplete_items``. Only the response-level status
# and genuine model output items (message/reasoning/function_call)
# govern the incomplete verdict. Without this guard, any turn where
# grok-composer invokes server-side search is misclassified as
# ``finish_reason="incomplete"`` and burns 3 fruitless continuation
# retries before failing with "Codex response remained incomplete
# after 3 continuation attempts". client-side function/custom tool
# calls keep their own in_progress handling below (they are skipped,
# not awaited).
_SERVER_SIDE_TOOL_CALL_TYPES = {
"web_search_call",
"file_search_call",
"code_interpreter_call",
"image_generation_call",
"computer_call",
"local_shell_call",
"mcp_call",
}
for item in output:
item_type = getattr(item, "type", None)
item_status = getattr(item, "status", None)
@ -1094,7 +1156,10 @@ def _normalize_codex_response(
else:
item_status = None
if item_status in {"queued", "in_progress", "incomplete"}:
if (
item_status in {"queued", "in_progress", "incomplete"}
and item_type not in _SERVER_SIDE_TOOL_CALL_TYPES
):
has_incomplete_items = True
saw_streaming_or_item_incomplete = True

View file

@ -603,6 +603,20 @@ def compress_context(
force=True,
)
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
# the completed old session before its details are lost.
_old_sid_for_event = locals().get("old_session_id")
if getattr(agent, "event_callback", None):
try:
agent.event_callback("session:compress", {
"platform": agent.platform or "",
"session_id": agent.session_id,
"old_session_id": _old_sid_for_event or "",
"compression_count": agent.context_compressor.compression_count,
})
except Exception as e:
logger.debug("event_callback error on session:compress: %s", e)
# Keep the post-compression rough estimate for diagnostics, but do not
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
# can remain above threshold even after the next real API request fits.

View file

@ -474,6 +474,7 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@ -489,6 +490,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.
Returns:
@ -510,6 +513,7 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
@ -3752,8 +3756,30 @@ def run_conversation(
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(assistant_msg)
for tc in assistant_message.tool_calls:
if tc.function.name not in agent.valid_tool_names:
content = f"Tool '{tc.function.name}' does not exist. Available tools: {available}"
_tc_name = tc.function.name
if _tc_name not in agent.valid_tool_names:
# A blank/whitespace-only name is not a typo the
# model can fuzzy-correct toward a real tool — it is
# almost always a weak open model echoing tool-call
# XML/JSON it saw in file or tool output (#47967:
# <tool_call>/<invoke name=...> payloads in a file
# prime mimo/nemotron-class models to emit empty
# structured calls). Dumping the full tool catalog
# in that case feeds the priming loop more names to
# mimic and inflates context 3-4x across retries, so
# send a terse error that tells the model in-context
# tool-call syntax is DATA, not a call to make.
if not (_tc_name or "").strip():
content = (
"Tool call rejected: the tool name was empty. "
"If tool-call XML or JSON appeared in file "
"contents or tool output, that is data — do "
"not re-emit it as a tool call. To call a "
"tool, use a valid name from your tool list; "
"otherwise reply in plain text."
)
else:
content = f"Tool '{_tc_name}' does not exist. Available tools: {available}"
else:
content = "Skipped: another tool call in this turn used an invalid name. Please retry this tool call."
messages.append({

View file

@ -57,6 +57,11 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
DEFAULT_MIN_IDLE_HOURS = 2
DEFAULT_STALE_AFTER_DAYS = 30
DEFAULT_ARCHIVE_AFTER_DAYS = 90
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
# deterministic inactivity prune (apply_automatic_transitions) still runs
# whenever the curator is enabled; only the opinionated, aux-model-cost
# consolidation pass is opt-in.
DEFAULT_CONSOLIDATE = False
# ---------------------------------------------------------------------------
@ -182,6 +187,22 @@ def get_prune_builtins() -> bool:
return bool(cfg.get("prune_builtins", True))
def get_consolidate() -> bool:
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
OFF by default. When off, a curator run does ONLY the deterministic
inactivity prune (mark stale / archive long-unused skills) and skips the
forked aux-model review entirely no consolidation, no umbrella-building,
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
LLM pass that merges overlapping skills into class-level umbrellas.
The explicit ``hermes curator run --consolidate`` flag overrides this for
a single invocation regardless of the config value.
"""
cfg = _load_config()
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
# ---------------------------------------------------------------------------
# Idle / interval check
# ---------------------------------------------------------------------------
@ -1408,25 +1429,38 @@ def run_curator_review(
on_summary: Optional[Callable[[str], None]] = None,
synchronous: bool = False,
dry_run: bool = False,
consolidate: Optional[bool] = None,
) -> Dict[str, Any]:
"""Execute a single curator review pass.
Steps:
1. Apply automatic state transitions (pure, no LLM).
2. If there are agent-created skills, spawn a forked AIAgent that runs
the LLM review prompt against the current candidate list.
2. If consolidation is enabled AND there are agent-created skills, spawn
a forked AIAgent that runs the LLM review prompt against the current
candidate list.
3. Update .curator_state with last_run_at and a one-line summary.
4. Invoke *on_summary* with a user-visible description.
If *synchronous* is True, the LLM review runs in the calling thread; the
default is to spawn a daemon thread so the caller returns immediately.
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
reads ``curator.consolidate`` from config (OFF by default). Passing
``True``/``False`` overrides the config for this invocation used by the
``hermes curator run --consolidate`` flag. When consolidation is off, only
the deterministic inactivity prune runs and the forked aux-model review is
skipped entirely (no aux-model cost).
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
and the LLM review pass is instructed to produce a report only no
skill_manage mutations, no terminal archive moves. The REPORT.md still
gets written and ``state.last_report_path`` still records it so users
can read what the curator WOULD have done.
can read what the curator WOULD have done. A dry-run also honors
*consolidate*: when consolidation is off, the preview only reports the
deterministic prune candidates.
"""
if consolidate is None:
consolidate = get_consolidate()
start = datetime.now(timezone.utc)
if dry_run:
# Count candidates without mutating state.
@ -1489,6 +1523,53 @@ def run_curator_review(
before_report = []
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
# Consolidation gate. When off (the default), the curator does ONLY the
# deterministic inactivity prune above — no forked aux-model review, no
# umbrella-building, no aux-model cost. Record the run, write a report
# reflecting the prune-only outcome, and return without spawning a fork.
if not consolidate:
final_summary = (
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
)
llm_meta = {
"final": "",
"summary": "skipped (consolidation off)",
"model": "",
"provider": "",
"tool_calls": [],
"error": None,
}
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
state2 = load_state()
state2["last_run_duration_seconds"] = elapsed
state2["last_run_summary"] = final_summary
try:
after_report = skill_usage.agent_created_report()
except Exception:
after_report = []
try:
report_path = _write_run_report(
started_at=start,
elapsed_seconds=elapsed,
auto_counts=counts,
auto_summary=auto_summary,
before_report=before_report,
before_names=before_names,
after_report=after_report,
llm_meta=llm_meta,
)
if report_path is not None:
state2["last_report_path"] = str(report_path)
except Exception as e:
logger.debug("Curator report write failed: %s", e, exc_info=True)
save_state(state2)
if on_summary:
try:
on_summary(f"curator: {final_summary}")
except Exception:
pass
return
llm_meta: Dict[str, Any] = {}
try:
candidate_list = _render_candidate_list()

View file

@ -46,7 +46,7 @@ import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple
from hermes_constants import get_hermes_home
from agent.skill_utils import is_excluded_skill_path
@ -208,13 +208,17 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
)
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
Returns the snapshot directory path, or ``None`` if the snapshot was
skipped (backup disabled, skills dir missing, or an IO error occurred
in which case we log at debug and return None so the curator never
aborts a pass because of a backup failure).
``protect_ids`` is forwarded to the prune step so callers can guarantee
specific snapshot ids survive even when they fall outside the keep
window (rollback passes the id it is about to restore from).
"""
if not is_enabled():
logger.debug("Curator backup disabled by config; skipping snapshot")
@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]:
pass
return None
_prune_old(keep=get_keep())
_prune_old(keep=get_keep(), protect=protect_ids)
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
return dest
def _prune_old(keep: int) -> List[str]:
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
and pruned independently on every call."""
ids. Snapshot ids in *protect* are never deleted even when they fall
outside the keep window rollback() uses this so the mandatory
pre-rollback safety snapshot can never evict the very snapshot being
restored. Staging dirs (``.rollback-staging-*``) are implementation
detail and pruned independently on every call."""
protect = protect or set()
backups = _backups_dir()
if not backups.exists():
return []
@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]:
entries.sort(key=lambda t: t[0], reverse=True)
deleted: List[str] = []
for _, path in entries[keep:]:
if path.name in protect:
continue
try:
shutil.rmtree(path)
deleted.append(path.name)
@ -564,7 +574,13 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
# out before touching anything — otherwise a failed extract could leave
# the user with no skills.
try:
snapshot_skills(reason=f"pre-rollback to {target.name}")
# Protect the target from this snapshot's prune step: at the steady
# keep limit, pruning the oldest snapshot would otherwise delete the
# very snapshot we are about to extract from.
snapshot_skills(
reason=f"pre-rollback to {target.name}",
protect_ids={target.name},
)
except Exception as e:
return (False, f"pre-rollback safety snapshot failed: {e}", None)

View file

@ -33,6 +33,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error
logger = logging.getLogger(__name__)
@ -430,16 +431,37 @@ class MemoryManager:
# -- Prefetch / recall ---------------------------------------------------
@staticmethod
def _strip_skill_scaffolding(text: str) -> Optional[str]:
"""Return memory-worthy user text, or None to skip the turn.
When a user invokes a /skill or /bundle, Hermes expands the turn into
a model-facing message that embeds the entire skill body. Feeding that
verbatim to memory providers pollutes their stores/embeddings with
prompt scaffolding instead of what the user actually asked. We recover
just the user's instruction here, once, for every provider — so this
is fixed for the whole provider fan-out, not per backend.
- Non-skill messages pass through unchanged.
- Skill turns with a user instruction return that instruction.
- Bare skill invocations (no instruction) return None callers skip
the turn, since there is no user content worth remembering.
"""
return extract_user_instruction_from_skill_message(text)
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"""Collect prefetch context from all providers.
Returns merged context text labeled by provider. Empty providers
are skipped. Failures in one provider don't block others.
"""
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return ""
parts = []
for provider in self._providers:
try:
result = provider.prefetch(query, session_id=session_id)
result = provider.prefetch(clean_query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
@ -460,10 +482,14 @@ class MemoryManager:
if not providers:
return
clean_query = self._strip_skill_scaffolding(query)
if not clean_query:
return
def _run() -> None:
for provider in providers:
try:
provider.queue_prefetch(query, session_id=session_id)
provider.queue_prefetch(clean_query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
@ -515,6 +541,11 @@ class MemoryManager:
if not providers:
return
clean_user_content = self._strip_skill_scaffolding(user_content)
if not clean_user_content:
return
user_content = clean_user_content
def _run() -> None:
for provider in providers:
try:

View file

@ -275,6 +275,11 @@ DEFAULT_CONTEXT_LENGTHS = {
# via a custom provider. Values sourced from models.dev (2026-04).
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
# OAuth-only slug; absent from GET /v1/models. xAI publishes a 200k
# usable context window for Composer 2.5 on Grok Build (SuperGrok /
# Premium+); /v1/responses additionally enforces a ~262144 input+output
# budget, but the usable context (what we track here) is 200k.
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest

View file

@ -8,6 +8,7 @@ import json
import logging
import os
import threading
import contextvars
from collections import OrderedDict
from pathlib import Path
@ -957,6 +958,80 @@ CONTEXT_FILE_MAX_CHARS = 20_000
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
# The cap scales with the model's context window so large-context models rarely
# truncate a project doc, while small-context models stay at the historical
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
# slice of the window on context files since they share the cached prefix with
# the system prompt, tools, memory, and the whole conversation.
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
"""Derive a char cap from the model's context window.
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
unknown/invalid, returns the flat default so behavior is unchanged.
"""
if not isinstance(context_length, int) or context_length <= 0:
return CONTEXT_FILE_MAX_CHARS
budget = int(
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
)
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
"""Return the context-file truncation limit.
Resolution order:
1. Explicit ``context_file_max_chars`` in config.yaml user knows best,
always wins (including over the dynamic cap).
2. Dynamic cap derived from the model's ``context_length`` when provided
(scales the budget to the window; floor 20K, ceiling 500K).
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
"""
try:
from hermes_cli.config import load_config
val = load_config().get("context_file_max_chars")
if isinstance(val, (int, float)) and val > 0:
return int(val)
except Exception as e:
logger.debug("Could not read context_file_max_chars from config: %s", e)
return _dynamic_context_file_max_chars(context_length)
# Collect truncation warnings so the caller (run_agent) can surface them.
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# =========================================================================
# Skills prompt cache
@ -1463,19 +1538,47 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
# Context files (SOUL.md, AGENTS.md, .cursorrules)
# =========================================================================
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
"""Head/tail truncation with a marker in the middle."""
def _truncate_content(
content: str,
filename: str,
max_chars: Optional[int] = None,
context_length: Optional[int] = None,
read_path: Optional[str] = None,
) -> str:
"""Head/tail truncation with a marker in the middle.
``filename`` is the human label used in warnings. ``read_path`` is the
concrete path the agent should ``read_file`` to recover the full content
(defaults to ``filename`` when not supplied). ``context_length`` lets the
cap scale to the model's window when no explicit config override is set.
"""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
if len(content) <= max_chars:
return content
target = read_path or filename
msg = (
f"⚠️ Context file {filename} TRUNCATED: "
f"{len(content)} chars exceeds limit of {max_chars}"
f"trim the file, pin a larger context_file_max_chars, or use a "
f"larger-context model!"
)
logger.warning(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]
tail = content[-tail_chars:]
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
marker = (
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
f"{len(content)} chars. The middle is omitted — if you need the full "
f"instructions, read the complete file with the read_file tool: "
f"{target}]\n\n"
)
return head + marker + tail
def load_soul_md() -> Optional[str]:
def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
"""Load SOUL.md from HERMES_HOME and return its content, or None.
Used as the agent identity (slot #1 in the system prompt). When this
@ -1496,14 +1599,17 @@ def load_soul_md() -> Optional[str]:
if not content:
return None
content = _scan_context_content(content, "SOUL.md")
content = _truncate_content(content, "SOUL.md")
content = _truncate_content(
content, "SOUL.md", context_length=context_length,
read_path=str(soul_path),
)
return content
except Exception as e:
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
return None
def _load_hermes_md(cwd_path: Path) -> str:
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".hermes.md / HERMES.md — walk to git root."""
hermes_md_path = _find_hermes_md(cwd_path)
if not hermes_md_path:
@ -1520,13 +1626,16 @@ def _load_hermes_md(cwd_path: Path) -> str:
pass
content = _scan_context_content(content, rel)
result = f"## {rel}\n\n{content}"
return _truncate_content(result, ".hermes.md")
return _truncate_content(
result, ".hermes.md", context_length=context_length,
read_path=str(hermes_md_path),
)
except Exception as e:
logger.debug("Could not read %s: %s", hermes_md_path, e)
return ""
def _load_agents_md(cwd_path: Path) -> str:
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""AGENTS.md — top-level only (no recursive walk)."""
for name in ["AGENTS.md", "agents.md"]:
candidate = cwd_path / name
@ -1536,13 +1645,16 @@ def _load_agents_md(cwd_path: Path) -> str:
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(result, "AGENTS.md")
return _truncate_content(
result, "AGENTS.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_claude_md(cwd_path: Path) -> str:
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
"""CLAUDE.md / claude.md — cwd only."""
for name in ["CLAUDE.md", "claude.md"]:
candidate = cwd_path / name
@ -1552,13 +1664,16 @@ def _load_claude_md(cwd_path: Path) -> str:
if content:
content = _scan_context_content(content, name)
result = f"## {name}\n\n{content}"
return _truncate_content(result, "CLAUDE.md")
return _truncate_content(
result, "CLAUDE.md", context_length=context_length,
read_path=str(candidate),
)
except Exception as e:
logger.debug("Could not read %s: %s", candidate, e)
return ""
def _load_cursorrules(cwd_path: Path) -> str:
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
cursorrules_content = ""
cursorrules_file = cwd_path / ".cursorrules"
@ -1585,10 +1700,17 @@ def _load_cursorrules(cwd_path: Path) -> str:
if not cursorrules_content:
return ""
return _truncate_content(cursorrules_content, ".cursorrules")
return _truncate_content(
cursorrules_content, ".cursorrules", context_length=context_length,
read_path=str(cwd_path / ".cursorrules"),
)
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
def build_context_files_prompt(
cwd: Optional[str] = None,
skip_soul: bool = False,
context_length: Optional[int] = None,
) -> str:
"""Discover and load context files for the system prompt.
Priority (first found wins only ONE project context type is loaded):
@ -1598,7 +1720,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
SOUL.md from HERMES_HOME is independent and always included when present.
Each context source is capped at 20,000 chars.
Each context source is capped before injection. The cap defaults to the
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
when *context_length* is provided, falling back to 20,000 chars otherwise.
An explicit ``context_file_max_chars`` in config.yaml always wins.
When *skip_soul* is True, SOUL.md is not included here (it was already
loaded via ``load_soul_md()`` for the identity slot).
@ -1611,17 +1737,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals
# Priority-based project context: first match wins
project_context = (
_load_hermes_md(cwd_path)
or _load_agents_md(cwd_path)
or _load_claude_md(cwd_path)
or _load_cursorrules(cwd_path)
_load_hermes_md(cwd_path, context_length)
or _load_agents_md(cwd_path, context_length)
or _load_claude_md(cwd_path, context_length)
or _load_cursorrules(cwd_path, context_length)
)
if project_context:
sections.append(project_context)
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
if not skip_soul:
soul_content = load_soul_md()
soul_content = load_soul_md(context_length)
if soul_content:
sections.append(soul_content)

View file

@ -26,6 +26,91 @@ _skill_commands_platform: Optional[str] = None
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
# ---------------------------------------------------------------------------
# Skill-scaffolding markers and the canonical extractor.
#
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
# model-facing message that embeds the full skill body plus scaffolding. That
# expanded text is what flows into the agent loop — and into memory providers
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
# otherwise capture the entire skill body instead of what the user actually
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
# user's instruction so memory stays clean.
#
# These markers MUST stay byte-identical to the builders below
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
# agent/skill_bundles.py). They are co-located with the single-skill builder
# on purpose, and the bundle markers are asserted against the bundle builder in
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
# ---------------------------------------------------------------------------
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
_SINGLE_SKILL_INSTRUCTION = (
"The user has provided the following instruction alongside the skill invocation: "
)
_RUNTIME_NOTE = "\n\n[Runtime note:"
_BUNDLE_MARKER = " skill bundle,"
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
"""Recover the user's instruction from a slash-skill-expanded turn.
Returns:
- The original string unchanged when it is NOT skill scaffolding
(a normal user message passes straight through).
- The extracted user instruction when the scaffolding carried one.
- ``None`` when the content is skill scaffolding with no user
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
memory providers should skip the turn in that case there is no
user content worth storing.
"""
if not isinstance(content, str):
return None
if not content.startswith(_SKILL_INVOCATION_PREFIX):
return content
if _BUNDLE_MARKER in content:
return _extract_bundle_user_instruction(content)
if _SINGLE_SKILL_MARKER in content:
return _extract_single_skill_user_instruction(content)
return None
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
# Single-skill format appends the user instruction after the skill body, so
# the last occurrence is the user-provided one; the body may quote this text.
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
runtime_idx = instruction.find(_RUNTIME_NOTE)
if runtime_idx >= 0:
instruction = instruction[:runtime_idx]
instruction = instruction.strip()
return instruction or None
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
# Bundle format puts the user instruction before the loaded skills, so the
# first occurrence is the user-provided one.
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
if marker_idx < 0:
return None
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
if first_skill_idx >= 0:
instruction = instruction[:first_skill_idx]
instruction = instruction.strip()
return instruction or None
def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering.

View file

@ -43,14 +43,20 @@ EXCLUDED_SKILL_DIRS = frozenset(
)
)
# Supporting files live inside a skill package and are loaded explicitly via
# skill_view(skill, file_path=...). They are not standalone skills and must not
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
# archive workflow preserves a complete old skill package under references/.
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool:
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
"""True if *path* should be skipped by active skill scanners.
Use this on every SKILL.md path produced by ``rglob`` to prune
dependency, virtualenv, VCS, and cache directories. Centralising the
check here keeps every skill-scanning site in sync with the shared
exclusion set.
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
support-package paths. Centralising the check here keeps every
skill-scanning site in sync with the shared exclusion set.
Accepts a Path or string.
"""
@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool:
except AttributeError:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
)
def is_skill_support_path(path) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
progressive-disclosure support areas when they sit directly inside a skill
directory containing ``SKILL.md``. They are not active discovery roots for
standalone skills. A preserved package such as
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
unless the caller explicitly loads it via ``file_path``.
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
discoverable because their ``scripts`` component is not directly under a
directory that contains ``SKILL.md``.
"""
path_obj = path if isinstance(path, Path) else Path(str(path))
parts = path_obj.parts
# Last component may be a file or candidate skill directory name. Only
# components before the leaf can be containing support directories.
for idx, part in enumerate(parts[:-1]):
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if (skill_root / "SKILL.md").exists():
return True
return False
# ── Lazy YAML loader ─────────────────────────────────────────────────────
@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str:
def iter_skill_index_files(skills_dir: Path, filename: str):
"""Walk skills_dir yielding sorted paths matching *filename*.
Excludes Hermes metadata, VCS, virtualenv/dependency, and cache
directories so dependencies cannot register nested skills.
Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill
support directories. Support directories (references/templates/assets/
scripts) can contain arbitrary markdown and even archived package
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):

View file

@ -40,6 +40,7 @@ from agent.prompt_builder import (
TASK_COMPLETION_GUIDANCE,
TOOL_USE_ENFORCEMENT_GUIDANCE,
TOOL_USE_ENFORCEMENT_MODELS,
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
@ -82,6 +83,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# we resolve through ``_ra()`` to honor those patches.
_r = _ra()
# Resolve the model's context window once so context-file caps can scale
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
# None falls back to the historical flat default. This value is stable for
# the life of the conversation, so it does not threaten prompt caching.
_ctx_len: Optional[int] = None
_cc = getattr(agent, "context_compressor", None)
if _cc is not None:
_cc_len = getattr(_cc, "context_length", None)
if isinstance(_cc_len, int) and _cc_len > 0:
_ctx_len = _cc_len
# ── Stable tier ────────────────────────────────────────────────
stable_parts: List[str] = []
@ -90,7 +102,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# cwd project instructions disabled.
_soul_loaded = False
if agent.load_soul_identity or not agent.skip_context_files:
_soul_content = _r.load_soul_md()
_soul_content = _r.load_soul_md(_ctx_len)
if _soul_content:
stable_parts.append(_soul_content)
_soul_loaded = True
@ -333,7 +345,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# dir — the user's real cwd there, but the install dir for the gateway
# daemon, which is why the gateway sets TERMINAL_CWD.
context_files_prompt = _r.build_context_files_prompt(
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
context_length=_ctx_len)
if context_files_prompt:
context_parts.append(context_files_prompt)
@ -400,7 +413,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
warm across turns.
"""
parts = build_system_prompt_parts(agent, system_message=system_message)
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
# Surface context-file truncation warnings through the normal agent status
# channel so gateway/CLI users see them in chat instead of only in logs.
for warning in drain_truncation_warnings():
agent._emit_status(warning)
return joined
def invalidate_system_prompt(agent: Any) -> None:

View file

@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
from agent.transports.types import ToolCall
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_"
_MCP_PREFIX = "mcp__"
text_parts = []
reasoning_parts = []
@ -132,17 +132,25 @@ class AnthropicTransport(ProviderTransport):
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
# On the OAuth wire every tool carries a double-underscore
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
# Anthropic's single-underscore third-party classifier).
# Reverse it back to the name the registry/dispatcher knows.
# Two original forms map onto the same ``mcp__`` wire name:
# ``mcp__read_file`` <- bare native tool ``read_file``
# ``mcp__linear_get_issue`` <- MCP server tool
# ``mcp_linear_get_issue``
# Resolve by registry lookup, preferring whichever original
# is actually registered; never rewrite a name the LLM used
# that already resolves natively. GH-25255.
from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
name = stripped
if not _tool_registry.get_entry(name):
bare = name[len(_MCP_PREFIX):] # read_file
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
if _tool_registry.get_entry(single):
name = single
elif _tool_registry.get_entry(bare):
name = bare
tool_calls.append(
ToolCall(
id=block.id,

View file

@ -128,6 +128,65 @@ class ResponsesApiTransport(ProviderTransport):
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
response_tools = _responses_tools(tools)
# xAI server-side web search.
#
# grok models on xAI's /v1/responses surface (notably
# grok-composer-2.5-fast on SuperGrok OAuth) have a *native*,
# server-executed web search. When the model is handed a
# client-side function literally named ``web_search``, it routes
# the intent to that native engine — but because the tool is
# declared as a plain ``function`` rather than xAI's first-class
# ``{"type": "web_search"}`` built-in, the server-side search is
# dispatched but never reconciled: the response streams reasoning
# + ``web_search_call`` progress items, the searches never reach
# ``status="completed"`` in the assembled output, no final
# message is emitted, and ``_normalize_codex_response`` correctly
# sees reasoning-with-no-answer and reports ``incomplete``. The
# turn then burns 3 continuation retries and fails with "Codex
# response remained incomplete after 3 continuation attempts".
# Verified live against grok-composer-2.5-fast (2026-06).
#
# Fix: when the agent HAS a client-side ``web_search`` function (i.e.
# the user enabled the web toolset), declare xAI's native
# ``web_search`` built-in instead so the search actually runs to
# completion server-side and the model streams a real answer. The
# Responses API rejects two tools sharing the name ``web_search``
# (HTTP 400 "Duplicate tool names"), so we drop the client-side
# ``web_search`` function for the xAI path and let the native tool
# satisfy it. All other client-side tools (read_file, terminal,
# web_extract, MCP tools, …) are untouched and continue to dispatch
# through Hermes's agent loop.
#
# Scope: we ONLY swap in the native built-in when the client
# ``web_search`` was actually present. We do NOT force-enable Grok
# server-side search on turns where the user never had web enabled —
# that would silently route around Hermes's web-provider config and
# tool-trace/citation plumbing for every xai-oauth turn. The swap is
# a 1:1 replacement of an already-requested capability, not an
# additive grant.
#
# NOTE: for the swapped case this routes ``web_search`` to Grok's
# native search engine for xAI sessions instead of Hermes's
# configured web provider (Tavily/etc.), and those results bypass
# Hermes's tool-trace / citation plumbing (they arrive baked into the
# model's answer rather than as a tool result the loop observes).
# Scoped to ``is_xai_responses`` deliberately; narrow to specific
# models if a future grok variant should keep the client-side
# function.
if is_xai_responses and response_tools:
has_client_web_search = any(
isinstance(t, dict) and t.get("name") == "web_search"
for t in response_tools
)
if has_client_web_search:
filtered = [
t for t in response_tools
if not (isinstance(t, dict) and t.get("name") == "web_search")
]
filtered.append({"type": "web_search"})
response_tools = filtered
# ``tools`` MUST be omitted entirely when there are no functions to
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
@ -218,10 +277,28 @@ class ResponsesApiTransport(ProviderTransport):
kwargs.pop("timeout", None)
if is_codex_backend:
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
# The Codex backend rejects body-level ``extra_headers`` with
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
# to actual HTTP request headers (not body fields). We need
# these headers for cache-scope routing so prompt cache hits
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = str(session_id or "").strip()
if cache_scope_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["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers
max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:

View file

@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
install_safe_stdio,
@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id

View file

@ -286,7 +286,7 @@ async fn run_update(app: AppHandle) -> Result<()> {
emit_stage(&app, "rebuild", StageState::Running, None, None);
let started = Instant::now();
let rebuild_args: Vec<String> = vec!["desktop".into(), "--build-only".into()];
let rebuild = run_streamed(
let mut rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
@ -295,6 +295,33 @@ async fn run_update(app: AppHandle) -> Result<()> {
Some("rebuild"),
)
.await?;
// Retry-once: the first `--build-only` can return nonzero on a still-settling
// post-update tree or a network-blocked Electron fetch that our self-heal
// repaired mid-run. A second attempt then builds clean off the healed dist
// (the content-hash stamp makes it a near-no-op when the first actually
// succeeded). Without this the updater bails here and never reaches the
// relaunch below — the app updates but doesn't restart. Matches the
// retry-once `hermes update` already does above, and `hermes update`'s own
// desktop rebuild in cmd_update.
if rebuild_needs_retry(rebuild.exit_code) {
emit_log(
&app,
Some("rebuild"),
LogStream::Stdout,
"[rebuild] first desktop rebuild failed; retrying once (a self-healed \
Electron download builds clean on the second run)",
);
rebuild = run_streamed(
&app,
&hermes,
&rebuild_args,
&install_root,
&child_env,
Some("rebuild"),
)
.await?;
}
let rebuild_ms = started.elapsed().as_millis() as u64;
if rebuild.exit_code != Some(0) {
@ -533,6 +560,14 @@ fn is_locked(path: &Path) -> bool {
}
}
/// Whether the `desktop --build-only` rebuild should be retried once. Any
/// non-success exit qualifies: the common cause is a transient first-attempt
/// failure (still-settling tree / self-healed Electron download) that a clean
/// second run resolves.
fn rebuild_needs_retry(exit_code: Option<i32>) -> bool {
exit_code != Some(0)
}
/// Spawn `hermes <args>` from `cwd`, stream stdout/stderr as Log events on the
/// bootstrap channel, and return the exit code. Mirrors powershell::run_script
/// but for an arbitrary command (no install.ps1 -File wrapping).
@ -970,6 +1005,16 @@ mod tests {
assert_eq!(update_branch_from_args(["--update"]), None);
}
#[test]
fn rebuild_retries_only_on_failure() {
assert!(!rebuild_needs_retry(Some(0)), "a clean rebuild must not retry");
assert!(rebuild_needs_retry(Some(1)), "a failed rebuild retries once");
assert!(
rebuild_needs_retry(None),
"a killed/signalled rebuild (no exit code) retries once"
);
}
#[test]
fn parses_only_app_targets() {
assert_eq!(

View file

@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ
const { runBootstrap } = require('./bootstrap-runner.cjs')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
@ -44,6 +45,7 @@ const { readDirForIpc } = require('./fs-read-dir.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { worktreesForIpc } = require('./git-worktrees.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@ -2008,10 +2010,14 @@ async function applyUpdatesPosixInApp() {
}
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
cwd: updateRoot,
env,
stage: 'rebuild'
// Retry-once: a first rebuild can fail on a still-settling tree or a
// self-healed (network-blocked) Electron download; a second run builds clean
// off the healed dist so we reach the swap+relaunch below instead of bailing.
const rebuilt = await runRebuildWithRetry(attempt => {
if (attempt > 0) {
emitUpdateProgress({ stage: 'rebuild', message: 'Retrying the desktop rebuild…', percent: 60 })
}
return runStreamedUpdate(hermes, ['desktop', '--build-only'], { cwd: updateRoot, env, stage: 'rebuild' })
})
if (rebuilt.code !== 0) {
emitUpdateProgress({
@ -5106,14 +5112,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
// themes/context.tsx, so the window appears already themed.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true
}
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
})
if (IS_MAC) {
@ -5180,23 +5179,11 @@ function createWindow() {
// material before the renderer paints the app theme. See createSessionWindow.
show: false,
backgroundColor: getWindowBackgroundColor(),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
}
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.cjs.
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
})
if (IS_MAC) {

View file

@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url')
const SESSION_WINDOW_MIN_WIDTH = 420
const SESSION_WINDOW_MIN_HEIGHT = 620
// Shared webPreferences for every window that renders the chat transcript — the
// primary window AND the secondary session windows. Keeping it in one place is
// the whole point: the two BrowserWindow definitions in main.cjs used to be
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath) {
return {
preload: preloadPath,
contextIsolation: true,
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
}
}
// Build the renderer URL for a secondary window. The renderer uses a
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
@ -94,6 +117,7 @@ function createSessionWindowRegistry() {
module.exports = {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry,
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH

View file

@ -1,7 +1,11 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
const {
buildSessionWindowUrl,
chatWindowWebPreferences,
createSessionWindowRegistry
} = require('./session-windows.cjs')
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
// test fire the 'closed' event, mirroring the slice of the Electron API the
@ -175,3 +179,21 @@ test('registry trims the session id before keying', () => {
assert.equal(registry.has('s1'), true)
})
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)
})
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
const prefs = chatWindowWebPreferences('/some/preload.cjs')
assert.equal(prefs.preload, '/some/preload.cjs')
assert.equal(prefs.contextIsolation, true)
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})

View file

@ -0,0 +1,29 @@
'use strict'
/**
* Retry-once policy for the desktop `--build-only` rebuild during self-update.
*
* The first rebuild can return nonzero on a still-settling post-update tree or a
* network-blocked Electron fetch that the installer's self-heal repaired mid-run.
* A second attempt then builds clean off the healed dist (the content-hash stamp
* makes it a near-no-op when the first actually succeeded). Without the retry the
* updater bails before the relaunch step the app updates but doesn't restart.
*/
function shouldRetryRebuild(code) {
return code !== 0
}
/**
* Run `rebuild()` (async, resolves `{ code, ... }`), retrying once on failure.
* Returns the final result.
*/
async function runRebuildWithRetry(rebuild) {
let result = await rebuild(0)
if (shouldRetryRebuild(result.code)) {
result = await rebuild(1)
}
return result
}
module.exports = { shouldRetryRebuild, runRebuildWithRetry }

View file

@ -0,0 +1,55 @@
/**
* Tests for electron/update-rebuild.cjs the retry-once policy for the desktop
* `--build-only` rebuild during self-update.
*
* Run with: node --test electron/update-rebuild.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* Why this matters: a first rebuild can return nonzero on a still-settling tree
* or a self-healed (network-blocked) Electron download. Without a second attempt
* the updater bails before the relaunch step the app updates but never restarts
* (the field report behind this fix). The retry must fire on failure, not on
* success, and must run at most twice.
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const { shouldRetryRebuild, runRebuildWithRetry } = require('./update-rebuild.cjs')
test('shouldRetryRebuild retries only on a non-success exit', () => {
assert.equal(shouldRetryRebuild(0), false)
assert.equal(shouldRetryRebuild(1), true)
assert.equal(shouldRetryRebuild(null), true)
})
test('a clean first rebuild runs once and does not retry', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 0 })
})
assert.deepEqual(codes, [0])
assert.equal(result.code, 0)
})
test('a failed first rebuild retries once and succeeds', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: attempt === 0 ? 1 : 0 })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 0)
})
test('a rebuild that keeps failing runs at most twice and reports the failure', async () => {
const codes = []
const result = await runRebuildWithRetry(attempt => {
codes.push(attempt)
return Promise.resolve({ code: 1, error: 'rebuild-failed' })
})
assert.deepEqual(codes, [0, 1])
assert.equal(result.code, 1)
assert.equal(result.error, 'rebuild-failed')
})

View file

@ -21,7 +21,7 @@
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
"postbuild": "node scripts/assert-dist-built.cjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 node scripts/run-electron-builder.cjs",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
"dist:mac": "npm run build && npm run builder -- --mac",
@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@ -117,7 +117,7 @@
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "^40.9.3",
"electron": "40.10.2",
"electron-builder": "^26.8.1",
"eslint": "^9.39.4",
"eslint-plugin-perfectionist": "^5.9.0",
@ -134,8 +134,7 @@
"wait-on": "^9.0.5"
},
"build": {
"electronVersion": "40.9.3",
"electronDist": "../../node_modules/electron/dist",
"electronVersion": "40.10.2",
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",

View file

@ -24,6 +24,11 @@ const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes cop
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));

View file

@ -0,0 +1,57 @@
"use strict"
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
const fs = require("node:fs")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
const args = []
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)

View file

@ -15,7 +15,9 @@ import { Backdrop } from '@/components/Backdrop'
import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorState } from '@/components/ui/error-state'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle, toRuntimeMessage } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
@ -38,11 +40,12 @@ import {
$lastVisibleMessageIsUser,
$messages,
$messagesEmpty,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
sessionPinId
} from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { isSecondaryWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { routeSessionId } from '../routes'
@ -86,7 +89,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>
onRestoreToMessage?: (messageId: string) => Promise<void>
onRetryResume: (sessionId: string) => void
onTranscribeAudio?: (audio: Blob) => Promise<string>
onDismissError?: (messageId: string) => void
}
interface ChatHeaderProps {
@ -121,10 +126,10 @@ function ChatHeader({
? pinnedSessionIds.includes(selectedSessionId)
: false
// A brand-new session has no session to pin/delete/rename, so the header is
// just a dead "New session" label + chevron. Drop it (and its border)
// entirely until there's a real session to act on.
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@ -272,9 +277,12 @@ export function ChatView({
onEdit,
onReload,
onRestoreToMessage,
onTranscribeAudio
onRetryResume,
onTranscribeAudio,
onDismissError
}: ChatViewProps) {
const location = useLocation()
const { t } = useI18n()
const activeSessionId = useStore($activeSessionId)
const awaitingResponse = useStore($awaitingResponse)
const busy = useStore($busy)
@ -296,6 +304,7 @@ export function ChatView({
const messagesEmpty = useStore($messagesEmpty)
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
const selectedSessionId = useStore($selectedStoredSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const routedSessionId = routeSessionId(location.pathname)
const isRoutedSessionView = Boolean(routedSessionId)
@ -315,9 +324,21 @@ export function ChatView({
// session exists — even if it has zero messages (a brand-new routed
// session). The flicker where `busy` flips true briefly during hydrate
// is handled by `threadLoadingState`'s last-visible-user gate.
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
//
// resumeExhausted: the bounded auto-retry in use-route-resume gave up on this
// routed session (gateway RPC + REST fallback failed through every attempt).
// Suppress the loader and show an explicit error + manual Retry instead of
// spinning forever. Gated on the route matching so a stale latch from another
// session can't blank the current one.
const resumeExhausted = isRoutedSessionView && resumeExhaustedSessionId === routedSessionId
const loadingSession =
!resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
const showChatBar = !loadingSession
// Hide the composer in the exhausted error state too: there's no live runtime
// to send to until a retry rebinds one.
const showChatBar = !loadingSession && !resumeExhausted
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
@ -432,6 +453,7 @@ export function ChatView({
loading={threadLoading}
onBranchInNewChat={onBranchInNewChat}
onCancel={onCancel}
onDismissError={onDismissError}
onRestoreToMessage={onRestoreToMessage}
sessionId={activeSessionId}
sessionKey={threadKey}
@ -465,6 +487,21 @@ export function ChatView({
</Suspense>
)}
</ChatRuntimeBoundary>
{resumeExhausted && routedSessionId && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<div className="grid justify-items-center">
<Button onClick={() => onRetryResume(routedSessionId)} size="sm" variant="outline">
{t.desktop.resumeRetry}
</Button>
</div>
</ErrorState>
</div>
)}
{showChatBar && <ScrollToBottomButton />}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />

View file

@ -13,7 +13,7 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
@ -52,7 +52,10 @@ import {
$currentCwd,
$freshDraftReady,
$gatewayState,
$messages,
$messagingSessions,
$resumeFailedSessionId,
$resumeExhaustedSessionId,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
@ -199,6 +202,8 @@ export function DesktopController() {
const activeSessionId = useStore($activeSessionId)
const currentCwd = useStore($currentCwd)
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
@ -736,6 +741,49 @@ export function DesktopController() {
[branchCurrentSession, refreshSessions]
)
// Clear a failed turn's red error banner from the transcript. Errors are
// renderer-local state (never persisted), so dismissing is purely a view +
// session-cache edit. A message that errored before emitting any visible
// text is a bare error placeholder → drop it entirely; one that streamed
// partial output then failed keeps its content and just sheds the error.
// Both the per-runtime cache AND the live $messages view must be updated:
// `preserveLocalAssistantErrors` re-grafts any still-errored message it
// finds in the view onto the next session.info flush, so clearing only the
// cache would let the heartbeat resurrect the banner.
const dismissError = useCallback(
(messageId: string) => {
const runtimeSessionId = activeSessionIdRef.current
if (!runtimeSessionId) {
return
}
const clearErrorIn = (messages: ChatMessage[]): ChatMessage[] =>
messages.flatMap(message => {
if (message.id !== messageId || !message.error) {
return [message]
}
if (!chatMessageText(message).trim() && !message.parts.some(part => part.type !== 'text')) {
return []
}
return [{ ...message, error: undefined, pending: false }]
})
// View first: the flush below reads $messages as the "current" baseline
// for error preservation, so the banner must be gone from it before the
// cache update triggers a re-sync.
setMessages(clearErrorIn($messages.get()))
updateSessionState(runtimeSessionId, state => ({
...state,
messages: clearErrorIn(state.messages)
}))
},
[activeSessionIdRef, updateSessionState]
)
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()
@ -845,6 +893,8 @@ export function DesktopController() {
gatewayState,
locationPathname: location.pathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@ -994,6 +1044,7 @@ export function DesktopController() {
void removeSession(selectedStoredSessionId)
}
}}
onDismissError={dismissError}
onEdit={editMessage}
onPasteClipboardImage={() => void composer.pasteClipboardImage()}
onPickFiles={() => void composer.pickContextPaths('file')}
@ -1002,6 +1053,7 @@ export function DesktopController() {
onReload={reloadFromMessage}
onRemoveAttachment={id => void composer.removeAttachment(id)}
onRestoreToMessage={restoreToMessage}
onRetryResume={sessionId => void resumeSession(sessionId, true)}
onSteer={steerPrompt}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}

View file

@ -1102,8 +1102,13 @@ export function useMessageStream({
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isActiveEvent) {
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
notify({
id: `gateway-error:${errorMessage}`,
kind: 'error',
title: 'Hermes error',
message: errorMessage

View file

@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session'
import { useRouteResume } from './use-route-resume'
interface HarnessProps {
@ -13,6 +15,8 @@ interface HarnessProps {
gatewayState: string
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
resumeFailedSessionId?: null | string
resumeExhaustedSessionId?: null | string
routedSessionId: null | string
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: null | string
@ -20,8 +24,12 @@ interface HarnessProps {
startFreshSessionDraft: (focus: boolean) => unknown
}
function RouteResumeHarness(props: HarnessProps) {
useRouteResume(props)
function RouteResumeHarness({
resumeFailedSessionId = null,
resumeExhaustedSessionId = null,
...props
}: HarnessProps) {
useRouteResume({ ...props, resumeExhaustedSessionId, resumeFailedSessionId })
return null
}
@ -256,3 +264,212 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
describe('useRouteResume bounded auto-retry after a failed resume', () => {
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
setResumeExhaustedSessionId(null)
})
// Common stranded-window props: gateway open, route on the session, no runtime
// yet, and the ref already synced to the route (resumeSession sets it at entry
// before failing) — the exact state that defeats the main effect's self-heal.
function strandedProps(resumeSession: (sid: string, focus: boolean) => Promise<unknown>) {
return {
activeSessionId: null,
activeSessionIdRef: { current: null } as MutableRefObject<null | string>,
creatingSessionRef: { current: false },
currentView: 'chat',
freshDraftReady: false,
gatewayState: 'open',
locationPathname: '/session-1',
resumeSession,
routedSessionId: 'session-1',
runtimeIdByStoredSessionIdRef: { current: new Map<string, string>() },
selectedStoredSessionId: 'session-1',
// Synced to the route by the failed resume's synchronous entry-write.
selectedStoredSessionIdRef: { current: 'session-1' } as MutableRefObject<null | string>,
startFreshSessionDraft: vi.fn()
}
}
it('retries the resume on backoff when the routed session is flagged as failed', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="session-1" />)
// The main effect fires one resume on mount (pathname-changed). Clear it so
// we assert purely the bounded-retry effect's scheduled retry below.
resumeSession.mockClear()
// No immediate fire — the retry is scheduled behind the backoff timer.
expect(resumeSession).not.toHaveBeenCalled()
// First backoff window (1s) elapses → one retry.
vi.advanceTimersByTime(1_000)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('does NOT retry a failed session that is not the routed one', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
// The failure flag points at a different session than the route.
render(<RouteResumeHarness {...strandedProps(resumeSession)} resumeFailedSessionId="other-session" />)
resumeSession.mockClear() // drop the mount resume
vi.advanceTimersByTime(10_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('skips the scheduled retry if the session already recovered when the timer fires', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume
// A resume landed while we waited: runtime is now bound.
props.activeSessionIdRef.current = 'runtime-1'
vi.advanceTimersByTime(8_000)
expect(resumeSession).not.toHaveBeenCalled()
})
it('stops retrying after MAX_RESUME_RETRIES consecutive failures', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Model the real re-arm loop: resumeSession clears $resumeFailedSessionId at
// entry (null) and a repeat failure re-sets it ('session-1'). That null->id
// toggle is what re-runs the effect and advances the bounded counter. The
// routed session never changes, so the counter is NOT reset between cycles.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear() // drop the mount resume; count only the retries
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000) // fire the scheduled retry (if any)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />) // cleared at entry
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />) // re-armed on failure
}
// Capped at MAX_RESUME_RETRIES (4): a persistently dead backend can't
// hot-loop the resume forever.
expect(resumeSession.mock.calls.length).toBe(4)
// Once auto-retry gives up, the exhausted latch is armed for the routed
// session so the chat view can swap the perpetual loader for an explicit
// error + manual Retry instead of spinning forever.
expect($resumeExhaustedSessionId.get()).toBe('session-1')
})
it('does not arm the exhausted latch while retries remain', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
// Two failure cycles — still under the 4-retry cap, so the latch must stay
// clear and the loader keeps spinning (auto-recovery hasn't given up yet).
for (let i = 0; i < 2; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('clears a stale exhausted latch when the route moves off the stranded session', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Pre-arm the latch as if this session had exhausted its retries.
setResumeExhaustedSessionId('session-1')
// Route is now on a different, healthy session that is not flagged as
// failed — the retry effect's "route moved off" branch clears the latch.
render(
<RouteResumeHarness
{...props}
activeSessionId="runtime-2"
activeSessionIdRef={{ current: 'runtime-2' }}
locationPathname="/session-2"
resumeFailedSessionId={null}
routedSessionId="session-2"
selectedStoredSessionId="session-2"
selectedStoredSessionIdRef={{ current: 'session-2' }}
/>
)
expect($resumeExhaustedSessionId.get()).toBeNull()
})
it('resets the retry counter for a fresh backoff cycle when the exhausted latch clears (manual retry, same session)', () => {
vi.useFakeTimers()
const resumeSession = vi.fn(async () => undefined)
const props = strandedProps(resumeSession)
// Phase A — exhaust the bounded auto-retry (counter → MAX) like a dead
// backend. The resumeExhaustedSessionId prop stays null here: the hook sets
// the store, which doesn't feed back into the prop in this harness.
const { rerender } = render(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
resumeSession.mockClear()
for (let i = 0; i < 8; i += 1) {
vi.advanceTimersByTime(8_000)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeFailedSessionId="session-1" />)
}
expect(resumeSession.mock.calls.length).toBe(4) // capped
expect($resumeExhaustedSessionId.get()).toBe('session-1')
// Phase B — user clicks Retry on the SAME stranded session. resumeSession
// clears both latches at entry; the exhausted latch's armed->cleared edge
// must reset the attempt counter so a fresh bounded cycle runs, not a single
// one-shot attempt that immediately re-arms the error. Model the prop
// transitions: reflect the armed latch, then clear it (retry), then re-arm
// the failure latch on the fresh failure.
resumeSession.mockClear()
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId="session-1" resumeFailedSessionId="session-1" />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId={null} />)
rerender(<RouteResumeHarness {...props} resumeExhaustedSessionId={null} resumeFailedSessionId="session-1" />)
// A real retry fires again instead of staying pinned at MAX (which would
// dispatch nothing). Without the reset the counter stays >= MAX and this
// advance dispatches zero resumes.
vi.advanceTimersByTime(8_000)
expect(resumeSession.mock.calls.length).toBeGreaterThan(0)
})
it('does not burn retry attempts on unrelated re-renders during the backoff window', () => {
vi.useFakeTimers()
const props = strandedProps(vi.fn())
// Mount schedules the first backoff timer. Then re-render repeatedly with a
// fresh resumeSession identity (referential instability — a real dep change
// for the retry effect) WITHOUT ever letting the timer fire. The old code
// incremented the attempt counter at schedule time, so >= MAX re-renders
// armed the exhausted error with zero resumes actually dispatched. The fix
// only advances the counter when a timer truly fires, so the latch stays
// clear no matter how many spurious re-renders happen mid-backoff.
const { rerender } = render(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
for (let j = 0; j < 8; j += 1) {
rerender(
<RouteResumeHarness {...props} resumeFailedSessionId="session-1" resumeSession={vi.fn(async () => undefined)} />
)
}
expect($resumeExhaustedSessionId.get()).toBeNull()
})
})

View file

@ -1,6 +1,7 @@
import { type MutableRefObject, useEffect, useRef } from 'react'
import { isNewChatRoute } from '@/app/routes'
import { setResumeExhaustedSessionId } from '@/store/session'
interface RouteResumeOptions {
activeSessionId: string | null
@ -11,6 +12,17 @@ interface RouteResumeOptions {
gatewayState: string | undefined
locationPathname: string
resumeSession: (sessionId: string, focus: boolean) => Promise<unknown>
// Stored-session id whose most recent resume failed terminally (set by
// useSessionActions, mirrored from $resumeFailedSessionId). While this equals
// routedSessionId the window would otherwise latch on the loader forever, so
// the bounded-retry effect below re-attempts the resume.
resumeFailedSessionId: string | null
// Stored-session id whose bounded auto-retry has EXHAUSTED (mirrored from
// $resumeExhaustedSessionId). Only resumeSession clears this latch (manual
// Retry / reconnect / reselect) — the auto-retry loop never does — so its
// armed->cleared edge is an unambiguous "give me a fresh backoff cycle"
// signal the effect below uses to reset the attempt counter.
resumeExhaustedSessionId: string | null
routedSessionId: string | null
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
@ -18,6 +30,19 @@ interface RouteResumeOptions {
startFreshSessionDraft: (focus: boolean) => unknown
}
// Bounded auto-retry for a stranded session window. A resume can fail terminally
// (gateway RPC reject + REST fallback failure) on a transiently wedged backend —
// dead provider key, a runaway turn hogging the dispatcher, flaky DNS. Without a
// retry the loader latches forever. We retry with backoff, capped, so a
// genuinely dead backend doesn't hot-loop the resume.
const MAX_RESUME_RETRIES = 4
const RESUME_RETRY_BASE_MS = 1_000
const RESUME_RETRY_MAX_MS = 8_000
function resumeRetryDelayMs(attempt: number): number {
return Math.min(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * 2 ** attempt)
}
// HashRouter boot edge case: pathname briefly reads `/` before the hash is
// parsed. If the hash references a real session, defer; resume picks it up
// next tick. Without this, ctrl+R on `#/:sessionId` flashes 5 loading states.
@ -49,6 +74,8 @@ export function useRouteResume({
gatewayState,
locationPathname,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
@ -58,6 +85,16 @@ export function useRouteResume({
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
// Per-session retry bookkeeping for the bounded auto-retry effect below. Keyed
// by the session id we're retrying so switching chats resets the counter.
const retrySessionIdRef = useRef<string | null>(null)
const retryAttemptRef = useRef(0)
// Tracks the previous exhausted-latch value so we can detect its armed->cleared
// edge. resumeSession clears $resumeExhaustedSessionId on a manual Retry /
// reconnect / reselect; that transition is our cue to reset the attempt counter
// for a fresh backoff cycle on the SAME session (the auto-retry loop itself
// never touches this latch, so it can't spuriously trigger the reset).
const prevResumeExhaustedRef = useRef<string | null>(null)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
@ -139,4 +176,111 @@ export function useRouteResume({
selectedStoredSessionIdRef,
startFreshSessionDraft
])
// Bounded auto-retry: when the routed session's resume failed terminally
// (resumeFailedSessionId matches the route), schedule a backoff retry so the
// window recovers on its own instead of latching the loader forever. This is
// the safety net the main effect above can't provide: after a failed resume,
// selectedStoredSessionIdRef.current already equals the route (resumeSession
// sets it synchronously at entry) and the pathname/gateway are unchanged, so
// none of stuckOnRoutedSession / pathnameChanged / gatewayBecameOpen fire
// again. resumeSession clears resumeFailedSessionId on its next attempt; a
// success keeps it clear (the effect's guard then no-ops), a repeat failure
// re-arms it and we back off further, capped at MAX_RESUME_RETRIES.
useEffect(() => {
// Detect the exhausted-latch armed->cleared edge for the current route. Only
// resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect /
// reselect) — the auto-retry loop never touches it — so this transition
// uniquely means "the user asked for another go." Reset the attempt counter
// for a fresh bounded backoff cycle on the SAME session. Without this,
// retryAttemptRef stays pinned at MAX after exhaustion (the !stranded reset
// below only fires on a route CHANGE to a different session), so a manual
// retry on the same stranded session would get exactly ONE attempt and then
// immediately re-arm the exhausted error — never the renewed backoff cycle
// the store/session.ts + use-session-actions.ts comments promise. (Point 2)
const wasExhausted = prevResumeExhaustedRef.current
prevResumeExhaustedRef.current = resumeExhaustedSessionId
if (wasExhausted && wasExhausted === routedSessionId && resumeExhaustedSessionId !== wasExhausted) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (currentView !== 'chat' || gatewayState !== 'open') {
return
}
const stranded =
Boolean(routedSessionId) &&
resumeFailedSessionId === routedSessionId &&
!creatingSessionRef.current
if (!stranded) {
// Route moved off the stranded session (or it recovered) — reset the
// counter so a future failure on another session starts fresh, and clear
// any exhausted-latch armed for a session we're no longer viewing (never
// the current route: that's the error state we want to keep showing).
// resumeSession also clears it on a fresh attempt; this covers a plain
// route-change away from the stranded window.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = null
retryAttemptRef.current = 0
setResumeExhaustedSessionId(current => (current && current !== routedSessionId ? null : current))
}
return
}
// New stranded session id → reset the attempt counter.
if (retrySessionIdRef.current !== routedSessionId) {
retrySessionIdRef.current = routedSessionId
retryAttemptRef.current = 0
}
if (retryAttemptRef.current >= MAX_RESUME_RETRIES) {
// Give up auto-retrying a persistently dead backend; the user can still
// reconnect / reselect (which resets the counter via the branch above).
// Surface an explicit error + manual Retry in the chat view instead of
// spinning the loader forever — resumeSession (manual Retry / reconnect /
// reselect) clears this latch and resets the counter for a fresh cycle.
setResumeExhaustedSessionId(routedSessionId)
return
}
const attempt = retryAttemptRef.current
const sessionId = routedSessionId as string
const timer = setTimeout(() => {
// Re-check liveness at fire time: a resume may have landed while we waited.
if (
creatingSessionRef.current ||
selectedStoredSessionIdRef.current !== sessionId ||
activeSessionIdRef.current !== null
) {
return
}
// Consume an attempt ONLY now that a resume is actually dispatching.
// Incrementing at schedule time (the old behavior) let unrelated dep
// changes during the 1s8s backoff window — a transient gatewayState
// flip, a non-referentially-stable resumeSession — clear the pending
// timer and re-run the effect, burning an attempt without any resume
// having fired. A flapping backend could then hit MAX in a couple of
// re-renders with far fewer than MAX real attempts. (Point 3)
retryAttemptRef.current += 1
void resumeSession(sessionId, true)
}, resumeRetryDelayMs(attempt))
return () => clearTimeout(timer)
}, [
activeSessionIdRef,
creatingSessionRef,
currentView,
gatewayState,
resumeSession,
resumeFailedSessionId,
resumeExhaustedSessionId,
routedSessionId,
selectedStoredSessionIdRef
])
}

View file

@ -3,8 +3,9 @@ import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getSessionMessages } from '@/hermes'
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
import { $currentCwd } from '@/store/session'
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
import type { ClientSessionState } from '../../types'
@ -117,3 +118,142 @@ describe('createBackendSessionForSend profile routing', () => {
expect(params).toMatchObject({ profile: 'default' })
})
})
// ── Resume failure recovery (the "stuck loading session window" bug) ──────────
// When session.resume rejects AND the REST transcript fallback ALSO fails, the
// hook must (a) not throw out of the fallback (which stranded the loader), and
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onReady,
requestGateway
}: {
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
onReady(actions.resumeSession)
}, [actions.resumeSession, onReady])
return null
}
describe('resumeSession failure recovery', () => {
afterEach(() => {
cleanup()
setResumeFailedSessionId(null)
setMessages([])
vi.restoreAllMocks()
})
async function runResume(
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
): Promise<void> {
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} />)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
}
it('arms $resumeFailedSessionId when resume RPC and REST fallback both fail', async () => {
// session.resume rejects (e.g. timeout against a wedged backend)...
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
// ...and the REST transcript fallback also rejects (backend unreachable).
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
await runResume(requestGateway)
// The window is no longer silently stranded: the failure latch is armed for
// the stored session, which use-route-resume consumes to retry.
expect($resumeFailedSessionId.get()).toBe('stored-1')
})
it('does NOT arm the failure latch when the resume RPC fails but the REST fallback paints history', async () => {
// session.resume rejects, but the REST transcript fallback succeeds and
// hydrates a readable transcript — the window is NOT stranded.
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [
{ content: 'hello', role: 'user', timestamp: 1 },
{ content: 'hi there', role: 'assistant', timestamp: 2 }
],
session_id: 'stored-1'
} as never)
await runResume(requestGateway)
// Arming here would auto-retry a window that already shows history and,
// on exhaustion, blank that transcript behind the error overlay — a
// regression vs. plain fallback-success. The latch must stay clear.
expect($resumeFailedSessionId.get()).toBeNull()
// The fallback transcript is visible.
expect($messages.get().length).toBeGreaterThan(0)
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
throw new Error('request timed out: session.resume')
}
return {} as never
})
vi.mocked(getSessionMessages).mockRejectedValue(new Error('network down'))
// resumeSession must resolve (swallow the fallback failure), not reject.
await expect(runResume(requestGateway)).resolves.toBeUndefined()
})
it('leaves the failure latch clear when resume succeeds', async () => {
// Pre-arm to prove a successful resume clears it (entry-clear path).
setResumeFailedSessionId('stored-1')
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.resume') {
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
await runResume(requestGateway)
expect($resumeFailedSessionId.get()).toBeNull()
})
})

View file

@ -38,6 +38,8 @@ import {
setFreshDraftReady,
setIntroSeed,
setMessages,
setResumeExhaustedSessionId,
setResumeFailedSessionId,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
@ -579,6 +581,15 @@ export function useSessionActions({
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS
// attempt fails terminally (RPC reject + REST fallback failure).
setResumeFailedSessionId(current => (current === storedSessionId ? null : current))
// Also clear the exhausted-latch: a fresh attempt (manual Retry, reconnect,
// reselect) gives the bounded auto-retry counter a clean cycle, so the
// chat view drops the error state and shows the loader again.
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
@ -769,13 +780,41 @@ export function useSessionActions({
return
}
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
// The gateway resume RPC failed. Try the REST transcript as a fallback
// so the window at least shows history. CRITICAL: this fallback must be
// wrapped in its own try — if it ALSO throws (wedged/unreachable backend,
// the common case when resume failed in the first place), an unguarded
// throw here skips setMessages AND leaves activeSessionId null with an
// empty transcript. That is the exact state the thread loader latches on
// forever (messagesEmpty && !activeSessionId) with no recovery path —
// the "open in new window stays stuck loading, even after a nap" bug.
try {
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
if (!isCurrentResume()) {
return
if (!isCurrentResume()) {
return
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
// use-route-resume re-attempts the resume on the next render / window
// focus / gateway reconnect instead of stranding the loader.
}
if (isCurrentResume() && $messages.get().length === 0) {
// Arm the self-heal ONLY when the window is still empty: the gateway
// resume rejected AND the REST fallback failed to paint a transcript.
// That is the exact stranded state the loader latches on
// (messagesEmpty && !activeSessionId), and matches $resumeFailedSessionId's
// documented contract. If the REST fallback DID paint history, the
// window is readable — arming here would needlessly auto-retry and,
// once retries exhaust, blank that visible transcript behind the
// exhausted-state error overlay (a regression vs. plain fallback success).
setResumeFailedSessionId(storedSessionId)
}
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
notifyError(err, copy.resumeFailed)
} finally {
if (isCurrentResume()) {

View file

@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$messages,
$turnStartedAt,
setCurrentFastMode,
setCurrentModel,
@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => {
expect($currentFastMode.get()).toBe(false)
})
})
function userMessage(id: string, text: string): ChatMessage {
return { id, role: 'user', parts: [{ type: 'text', text }] }
}
function assistantText(id: string, text: string): ChatMessage {
return { id, role: 'assistant', parts: [{ type: 'text', text }] }
}
function assistantError(id: string, error: string): ChatMessage {
return { id, role: 'assistant', parts: [], error, pending: false }
}
interface ViewHarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
}
function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId: null,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
// Wire the published view back into the real $messages atom the flush
// reads from, so the round-trip matches production.
setMessages: messages => $messages.set(messages)
})
onReady(cache)
return null
}
describe('useSessionStateCache — cross-thread error isolation', () => {
afterEach(() => {
cleanup()
$messages.set([])
})
it('does not leak a failed turn into another thread on switch', () => {
$messages.set([])
let cache!: Cache
const { rerender } = render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// Thread A ends its turn with an out-of-funds error and is on screen.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')]
}),
'stored-A'
)
})
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true)
// Switch to thread B (which completed cleanly). Its cached state syncs to
// the view while $messages still holds thread A's transcript.
rerender(<ViewHarness activeSessionId="thread-B" onReady={c => (cache = c)} />)
act(() => {
cache.updateSessionState(
'thread-B',
state => ({
...state,
busy: false,
messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')]
}),
'stored-B'
)
})
expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b'])
expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false)
})
it('still preserves a same-session local error a heartbeat dropped', () => {
$messages.set([])
let cache!: Cache
render(<ViewHarness activeSessionId="thread-A" onReady={c => (cache = c)} />)
// First paint establishes thread A as the on-screen session.
act(() => {
cache.updateSessionState(
'thread-A',
state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }),
'stored-A'
)
})
// A local error lands in the view (e.g. failAssistantMessage wrote it).
$messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')])
// A later same-session heartbeat carries cached state that lost the error.
act(() => {
cache.updateSessionState('thread-A', state => ({
...state,
busy: false,
messages: [userMessage('user-a', 'do the thing')]
}))
})
expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true)
})
})

View file

@ -79,6 +79,9 @@ export function useSessionStateCache({
const runtimeIdByStoredSessionIdRef = useRef(new Map<string, string>())
const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null)
const viewSyncRafRef = useRef<number | null>(null)
// Runtime id whose transcript currently occupies `$messages` — lets the
// flush below tell a same-session refresh from a thread switch.
const viewSessionIdRef = useRef<string | null>(null)
useEffect(() => {
activeSessionIdRef.current = activeSessionId
@ -142,12 +145,22 @@ export function useSessionStateCache({
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
// On a thread switch `$messages` still holds the *previous* thread, so
// preserving its local errors would graft that thread's failed turn (e.g.
// an out-of-funds error) onto this one — then cascade it everywhere as the
// polluted view becomes the next switch's baseline. Only carry errors
// across a same-session refresh; our cached state already keeps its own.
const nextMessages =
viewSessionIdRef.current === pending.sessionId
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
viewSessionIdRef.current = pending.sessionId
syncRuntimeMetadataToView(pending.state)
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)

View file

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View file

@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
getAuxiliaryModels(),
getHermesConfigRecord()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View file

@ -16,7 +16,7 @@ import {
} from '@/store/layout'
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { isSecondaryWindow } from '@/store/windows'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
@ -80,7 +80,10 @@ export function AppShell({
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
const hideTitlebarControls = isNewSessionWindow()
// Every secondary window (new-session scratch, subagent watch, cmd-click
// pop-out) is a compact side panel — none of them carry the full titlebar
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
const hideTitlebarControls = isSecondaryWindow()
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
// on macOS, where window controls sit on the left and are reported via

View file

@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -0,0 +1,129 @@
// Lists and blockquotes have chrome beside the text (markers, the quote
// border) whose side is driven by the box's CSS direction, which the
// unicode-bidi:plaintext rules never touch. These tests pin the split of
// responsibilities: ul/ol/blockquote carry dir="auto" so the browser
// resolves their box direction from content, inline code carries dir="ltr"
// so it neither votes in that resolution nor reorders, and plain prose
// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not
// resolve dir="auto", so the contract is asserted at the attribute level.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
const createdAt = new Date('2026-06-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hi' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(text: string): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ text }: { text: string }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('block-level direction chrome', () => {
it('lists carry dir="auto" so markers follow the resolved direction', async () => {
render(<Harness text={'מקומות:\n\n1. חוף גורדון\n2. שוק הכרמל\n\n- פריט\n- item'} />)
const item = await screen.findByText(/חוף גורדון/)
expect(item.closest('ol')?.getAttribute('dir')).toBe('auto')
const bullet = await screen.findByText(/פריט/)
expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto')
})
it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => {
render(<Harness text={'> ציטוט קצר בעברית'} />)
const quote = await screen.findByText(/ציטוט קצר/)
expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto')
})
it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => {
render(<Harness text={'1. `npm install` מתקין תלויות'} />)
const code = await screen.findByText('npm install')
expect(code.tagName).toBe('CODE')
expect(code.getAttribute('dir')).toBe('ltr')
expect(code.closest('ol')?.getAttribute('dir')).toBe('auto')
})
it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => {
render(<Harness text={'שלום לכולם'} />)
const paragraph = await screen.findByText(/שלום לכולם/)
expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false)
})
})

View file

@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string {
return tail || id
}
function safeEmbeddedImages(text: string) {
try {
return extractEmbeddedImages(text)
} catch {
return { cleanedText: text, images: [] as string[] }
}
}
function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] {
try {
return [...hermesDirectiveFormatter.parse(text)]
} catch {
return [{ kind: 'text', text }]
}
}
/**
* Renders text containing Hermes directives (`@file:...`, `@image:...`) as
* inline chips. Embedded MEDIA images render below as a thumbnail row.
*/
export function DirectiveContent({ text }: { text: string }) {
const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText])
const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text])
const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText])
return (
<span className="whitespace-pre-line" data-slot="aui_directive-text">

View file

@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
it('handles a fenced block larger than V8 spread-argument limit', () => {
// A single huge code block (e.g. a logged minified bundle) used to throw
// `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`.
const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n')
const input = `\`\`\`js\n${body}\n\`\`\``
expect(() => preprocessMarkdown(input)).not.toThrow()
})
})

View file

@ -19,8 +19,9 @@ import {
useState
} from 'react'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
// flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay
// module-scope so the prop identity is stable across renders.
function preprocessWithTailRepair(text: string): string {
return tailBoundedRemend(preprocessMarkdown(text))
try {
return tailBoundedRemend(preprocessMarkdown(text))
} catch {
return text
}
}
// Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full
@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn(
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)'
)
const MAX_MARKDOWN_CHARS = 200_000
function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) {
const chunks = useMemo(() => chunkByLines(text, 200), [text])
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
<ExpandableBlock className="p-2">
{chunks.map((chunk, index) => (
<div
className="[content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * 16}px` }}
>
{chunk.text}
</div>
))}
</ExpandableBlock>
</div>
)
}
function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) {
const { status } = useMessagePartText()
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
// Keep code parsing enabled while streaming so incomplete fenced blocks still
@ -484,19 +516,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
),
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
// plaintext scan. Fenced code never reaches this override; it goes
// through the code plugin's CodeCard path.
inlineCode: ({ className, ...props }: ComponentProps<'code'>) => (
<code className={className} dir="ltr" {...props} />
),
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
// Lists and blockquotes have chrome that sits *beside* the text
// (markers, the quote border), and that side is driven by the CSS
// `direction` of the box, which `unicode-bidi: plaintext` never
// touches — an RTL list otherwise renders its numbers stranded at
// the far left. `dir="auto"` lets the browser resolve the box
// direction from content; the plaintext rules in styles.css keep
// owning per-line text direction. Inline code carries `dir="ltr"`
// (see the `code` override) so it doesn't vote here either, same
// contract as the CSS isolate.
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} />
<ul className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} />
<ol className={cn('my-1 gap-0', className)} dir="auto" {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
[isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {
return <HugeTextFallback containerClassName={containerClassName} text={text} />
}
return (
<StreamdownTextPrimitive
components={components}

View file

@ -378,6 +378,20 @@ function IntroHarness() {
)
}
function DismissibleErrorHarness({ onDismissError }: { onDismissError: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [assistantErrorMessage('OpenRouter rejected the request (403).')],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onDismissError={onDismissError} />
</AssistantRuntimeProvider>
)
}
describe('assistant-ui streaming renderer', () => {
beforeEach(() => {
resizeObservers.clear()
@ -421,6 +435,23 @@ describe('assistant-ui streaming renderer', () => {
expect(screen.getByRole('alert').textContent).toContain('OpenRouter rejected the request (403).')
})
it('omits the dismiss control when no onDismissError handler is supplied', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
expect(screen.queryByRole('button', { name: 'Dismiss error' })).toBeNull()
})
it('invokes onDismissError with the errored message id when the dismiss control is clicked', () => {
const onDismissError = vi.fn()
render(<DismissibleErrorHarness onDismissError={onDismissError} />)
const dismiss = screen.getByRole('button', { name: 'Dismiss error' })
fireEvent.click(dismiss)
expect(onDismissError).toHaveBeenCalledTimes(1)
expect(onDismissError).toHaveBeenCalledWith('assistant-error-1')
})
// Scroll behavior (follow-at-bottom, escape-on-scroll-up, re-engage) is owned
// by the use-stick-to-bottom library and covered by its own test suite. We
// don't re-assert its scrollTop mechanics here — doing so in jsdom (no real

View file

@ -22,7 +22,7 @@ import {
resetThreadScroll,
setThreadAtBottom
} from '@/store/thread-scroll'
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
import { isSecondaryWindow } from '@/store/windows'
import { MessageRenderBoundary } from './message-render-boundary'
@ -134,13 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
const hiddenCount = firstVisible
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
const newSessionWindow = isNewSessionWindow()
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
const threadContentTopPad = newSessionWindow
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// hide the titlebar tool cluster + session header, but the OS traffic lights
// still sit in the top-left, so reserve the titlebar gap above the transcript.
const secondaryWindow = isSecondaryWindow()
// NB: CSS calc() requires whitespace around the +/- operator. This string is
// assigned verbatim to the --sticky-human-top inline style below (it does not
// go through Tailwind, which would auto-space it), so the spaces are load-
// bearing — without them the declaration is invalid, gets dropped, and the
// sticky user bubble falls back to its ~4px default and slides under the OS
// traffic lights.
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
const threadContentTopPad = secondaryWindow
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow()
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])
@ -247,10 +254,21 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
style={
{
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
} as CSSProperties
}
>
{secondaryWindow && (
// Secondary windows hide the titlebar chrome, so the scroller runs to
// the window's top edge and streamed text slides up under the OS
// traffic lights. Content padding alone scrolls away with the text — a
// fixed opaque strip (the titlebar's drag region) masks anything behind
// it and keeps the window draggable, matching the main window's header.
<div
aria-hidden="true"
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
/>
)}
<div
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
data-following={isAtBottom ? 'true' : 'false'}

View file

@ -91,7 +91,7 @@ import { attachmentDisplayText, attachmentId, pathLabel } from '@/lib/chat-runti
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
@ -169,6 +169,7 @@ export const Thread: FC<{
loading?: ThreadLoadingState
onBranchInNewChat?: (messageId: string) => void
onCancel?: () => Promise<void> | void
onDismissError?: (messageId: string) => void
onRestoreToMessage?: (messageId: string) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
@ -180,18 +181,19 @@ export const Thread: FC<{
loading,
onBranchInNewChat,
onCancel,
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
const messageComponents = useMemo(
() => ({
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />,
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => <UserMessage onCancel={onCancel} onRestoreToMessage={onRestoreToMessage} />
}),
[cwd, gateway, onBranchInNewChat, onCancel, onRestoreToMessage, sessionId]
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, sessionId]
)
const emptyPlaceholder = intro ? (
@ -245,9 +247,13 @@ const CenteredThreadSpinner: FC = () => {
)
}
const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }> = ({ onBranchInNewChat }) => {
const AssistantMessage: FC<{
onBranchInNewChat?: (messageId: string) => void
onDismissError?: (messageId: string) => void
}> = ({ onBranchInNewChat, onDismissError }) => {
const messageId = useAuiState(s => s.message.id)
const messageRuntime = useMessageRuntime()
const { t } = useI18n()
// PERF: this component must NOT subscribe to the streaming text. Every
// selector here returns a value that stays referentially stable across
@ -306,10 +312,20 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
)}
<MessagePrimitive.Error>
<ErrorPrimitive.Root
className="mt-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
className="mt-1.5 flex items-start gap-1.5 text-[0.78rem] leading-5 text-[color-mix(in_srgb,var(--dt-destructive)_78%,var(--ui-text-secondary))]"
role="alert"
>
<ErrorPrimitive.Message />
<ErrorPrimitive.Message className="min-w-0 flex-1" />
{onDismissError && (
<TooltipIconButton
className="-my-0.5 shrink-0 text-current opacity-70 hover:opacity-100"
onClick={() => onDismissError(messageId)}
side="top"
tooltip={t.assistant.thread.dismissError}
>
<XIcon className="size-3.5" />
</TooltipIconButton>
)}
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>

View file

@ -66,7 +66,7 @@ function CodeCardBody({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn(
'p-1.5 font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
'font-mono text-[0.7rem] leading-relaxed text-foreground/90 [&_pre]:m-0 [&_pre]:overflow-x-auto [&_pre]:bg-transparent! [&_pre]:px-2 [&_pre]:py-1.5 [&_pre]:font-mono [&_pre]:leading-relaxed',
className
)}
data-slot="code-card-body"

View file

@ -0,0 +1,52 @@
'use client'
import { type ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface ExpandableBlockProps {
children: ReactNode
className?: string
}
export function ExpandableBlock({ children, className }: ExpandableBlockProps) {
const innerRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
const [overflowing, setOverflowing] = useState(false)
useLayoutEffect(() => {
const el = innerRef.current
if (!el) {return}
const measure = () => setOverflowing(el.scrollHeight > 121)
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => observer.disconnect()
}, [])
return (
<div className="relative">
<div
className={cn('overflow-y-auto', expanded ? 'max-h-[40dvh]' : 'max-h-[7.5rem]', className)}
ref={innerRef}
>
{children}
</div>
{overflowing && (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Collapse' : 'Expand'}
className="absolute inset-x-0 bottom-0 flex h-7 cursor-pointer items-end justify-center bg-linear-to-t from-(--ui-chat-surface-background) to-transparent pb-1 text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => setExpanded(v => !v)}
type="button"
>
<ChevronDown className={cn('size-3.5 transition-transform', expanded && 'rotate-180')} />
</button>
)}
</div>
)
}

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { chunkByLines, exceedsHighlightBudget } from '@/components/chat/shiki-highlighter'
describe('exceedsHighlightBudget', () => {
it('highlights normal-sized blocks', () => {
expect(exceedsHighlightBudget('const x = 1\n'.repeat(100))).toBe(false)
})
it('skips highlighting past the line budget', () => {
expect(exceedsHighlightBudget('x\n'.repeat(5_000))).toBe(true)
})
it('skips highlighting past the char budget on few lines', () => {
expect(exceedsHighlightBudget('a'.repeat(200_000))).toBe(true)
})
it('short-circuits on char budget before line loop', () => {
expect(exceedsHighlightBudget('y\n'.repeat(250_000))).toBe(true)
})
})
describe('chunkByLines', () => {
it('keeps a small block as a single chunk', () => {
const code = 'a\nb\nc'
expect(chunkByLines(code, 200)).toEqual([{ text: code, lines: 3 }])
})
it('splits a large block and reconstructs it losslessly', () => {
const code = Array.from({ length: 1000 }, (_, i) => `line ${i}`).join('\n')
const chunks = chunkByLines(code, 200)
expect(chunks).toHaveLength(5)
expect(chunks.map(chunk => chunk.text).join('\n')).toBe(code)
expect(chunks.reduce((sum, chunk) => sum + chunk.lines, 0)).toBe(1000)
})
})

View file

@ -1,7 +1,7 @@
'use client'
import type { SyntaxHighlighterProps } from '@assistant-ui/react-streamdown'
import type { FC } from 'react'
import { type FC, useMemo } from 'react'
import ShikiHighlighter from 'react-shiki'
import {
@ -12,6 +12,7 @@ import {
CodeCardSubtitle,
CodeCardTitle
} from '@/components/chat/code-card'
import { ExpandableBlock } from '@/components/chat/expandable-block'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { codiconForLanguage, isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
@ -43,6 +44,74 @@ const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
'github-light-default': { '#6e7781': '#57606a' }
}
const MAX_HIGHLIGHT_CHARS = 150_000
const MAX_HIGHLIGHT_LINES = 3_000
const CHUNK_LINES = 200
const EST_LINE_PX = 16
export function exceedsHighlightBudget(code: string): boolean {
if (code.length > MAX_HIGHLIGHT_CHARS) {
return true
}
let lines = 1
let idx = code.indexOf('\n')
while (idx !== -1) {
if ((lines += 1) > MAX_HIGHLIGHT_LINES) {
return true
}
idx = code.indexOf('\n', idx + 1)
}
return false
}
interface CodeChunk {
text: string
lines: number
}
export function chunkByLines(code: string, perChunk: number): CodeChunk[] {
const lines = code.split('\n')
if (lines.length <= perChunk) {
return [{ text: code, lines: lines.length }]
}
const chunks: CodeChunk[] = []
for (let i = 0; i < lines.length; i += perChunk) {
const slice = lines.slice(i, i + perChunk)
chunks.push({ text: slice.join('\n'), lines: slice.length })
}
return chunks
}
const PlainCode: FC<{ code: string }> = ({ code }) => {
const chunks = useMemo(() => chunkByLines(code, CHUNK_LINES), [code])
if (chunks.length === 1) {
return <code className="block whitespace-pre">{code}</code>
}
return (
<>
{chunks.map((chunk, index) => (
<code
className="block whitespace-pre [content-visibility:auto]"
key={index}
style={{ containIntrinsicSize: `auto ${chunk.lines * EST_LINE_PX}px` }}
>
{chunk.text}
</code>
))}
</>
)
}
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
components: { Pre },
language,
@ -64,6 +133,7 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
const cleanLanguage = sanitizeLanguageTag(language || '')
const label = cleanLanguage && cleanLanguage !== 'unknown' ? cleanLanguage : ''
const plain = defer || exceedsHighlightBudget(trimmed)
return (
<CodeCard data-streaming={defer ? 'true' : undefined}>
@ -83,24 +153,26 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
/>
</CodeCardHeader>
<CodeCardBody>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{defer ? (
<code className="block whitespace-pre">{trimmed}</code>
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
<ExpandableBlock>
<Pre className="aui-shiki m-0 overflow-hidden bg-transparent p-0">
{plain ? (
<PlainCode code={trimmed} />
) : (
<ShikiHighlighter
addDefaultStyles={false}
as="div"
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
defaultColor="light-dark()"
delay={120}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{trimmed}
</ShikiHighlighter>
)}
</Pre>
</ExpandableBlock>
</CodeCardBody>
</CodeCard>
)

View file

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@ -66,8 +67,13 @@ export function ModelPickerDialog({
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -538,6 +538,10 @@ export const en: Translations = {
provider: 'Provider',
model: 'Model',
applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
@ -1729,6 +1733,7 @@ export const en: Translations = {
refresh: 'Refresh',
moreActions: 'More actions',
branchNewChat: 'Branch in new chat',
dismissError: 'Dismiss error',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading',
@ -1838,6 +1843,9 @@ export const en: Translations = {
regenerateFailed: 'Regenerate failed',
editFailed: 'Edit failed',
resumeFailed: 'Resume failed',
resumeStrandedTitle: "Couldn't load this session",
resumeStrandedBody: 'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch',
branchNeedsChat: 'Start or resume a chat before branching.',
sessionBusy: 'Session busy',

View file

@ -1864,6 +1864,7 @@ export const ja = defineLocale({
refresh: '更新',
moreActions: 'その他のアクション',
branchNewChat: '新しいチャットでブランチ',
dismissError: 'エラーを閉じる',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止',
@ -1973,6 +1974,9 @@ export const ja = defineLocale({
regenerateFailed: '再生成に失敗しました',
editFailed: '編集に失敗しました',
resumeFailed: '再開に失敗しました',
resumeStrandedTitle: 'このセッションを読み込めませんでした',
resumeStrandedBody: 'このセッションへの接続に失敗し、自動再試行も停止しました。ゲートウェイが実行中か確認してから、もう一度お試しください。',
resumeRetry: '再試行',
nothingToBranch: 'ブランチするものがありません',
branchNeedsChat: 'ブランチする前にチャットを開始または再開してください。',
sessionBusy: 'セッションが使用中',

View file

@ -430,6 +430,10 @@ export interface Translations {
provider: string
model: string
applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string
resetAllToMain: string
auxiliaryDesc: string
@ -1369,6 +1373,7 @@ export interface Translations {
refresh: string
moreActions: string
branchNewChat: string
dismissError: string
readAloudFailed: string
preparingAudio: string
stopReading: string
@ -1476,6 +1481,9 @@ export interface Translations {
regenerateFailed: string
editFailed: string
resumeFailed: string
resumeStrandedTitle: string
resumeStrandedBody: string
resumeRetry: string
nothingToBranch: string
branchNeedsChat: string
sessionBusy: string

View file

@ -1806,6 +1806,7 @@ export const zhHant = defineLocale({
refresh: '重新整理',
moreActions: '更多動作',
branchNewChat: '在新聊天中分支',
dismissError: '关闭错误',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀',
@ -1913,6 +1914,9 @@ export const zhHant = defineLocale({
regenerateFailed: '重新生成失敗',
editFailed: '編輯失敗',
resumeFailed: '繼續失敗',
resumeStrandedTitle: '無法載入此工作階段',
resumeStrandedBody: '與此工作階段的連線失敗,自動重試已停止。請確認閘道正在執行,然後重試。',
resumeRetry: '重試',
nothingToBranch: '沒有可分支的內容',
branchNeedsChat: '分支前請先開始或繼續一個聊天。',
sessionBusy: '工作階段忙碌中',

View file

@ -733,6 +733,10 @@ export const zh: Translations = {
provider: '提供方',
model: '模型',
applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
@ -1908,6 +1912,7 @@ export const zh: Translations = {
refresh: '刷新',
moreActions: '更多操作',
branchNewChat: '在新对话中分支',
dismissError: '关闭错误',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',
stopReading: '停止朗读',
@ -2016,6 +2021,9 @@ export const zh: Translations = {
regenerateFailed: '重新生成失败',
editFailed: '编辑失败',
resumeFailed: '恢复失败',
resumeStrandedTitle: '无法加载此会话',
resumeStrandedBody: '与此会话的连接失败,自动重试已停止。请确认网关正在运行,然后重试。',
resumeRetry: '重试',
nothingToBranch: '没有可分支的内容',
branchNeedsChat: '分支前请先开始或恢复一个对话。',
sessionBusy: '会话忙碌中',

View file

@ -151,12 +151,18 @@ function normalizeVisibleProse(text: string): string {
.join('')
}
function extend(out: string[], lines: string[]) {
for (const line of lines) {
out.push(line)
}
}
function pushProseFence(out: string[], indent: string, info: string, lines: string[]) {
if (info) {
out.push(`${indent}${info}`.trimEnd())
}
out.push(...lines)
extend(out, lines)
}
function findClosingFence(lines: string[], start: number, marker: string): number {
@ -241,7 +247,7 @@ function normalizeFenceBlocks(text: string): string {
}
if (closeIndex !== -1 && isUrlOnlyBlock(bodyLines)) {
out.push(...bodyLines)
extend(out, bodyLines)
index = closeIndex + 1
continue
@ -264,10 +270,10 @@ function normalizeFenceBlocks(text: string): string {
// any literal `$$` characters in the body don't collide with
// an outer math wrapper. No close emitted yet — streaming.
out.push(`${indent}${marker}math`)
out.push(...bodyLines)
extend(out, bodyLines)
} else {
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
extend(out, bodyLines)
}
break
@ -288,7 +294,7 @@ function normalizeFenceBlocks(text: string): string {
// colliding with our wrapper. Without this rewrite the block
// would render as a syntax-highlighted "latex" code listing.
out.push(`${indent}${marker}math`)
out.push(...bodyLines)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
@ -296,7 +302,7 @@ function normalizeFenceBlocks(text: string): string {
}
out.push(`${indent}${marker}${language}`)
out.push(...bodyLines)
extend(out, bodyLines)
out.push(`${indent}${marker}`)
index = closeIndex + 1
}

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@ -35,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
})

View file

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort
}
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()

View file

@ -218,6 +218,23 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
export const $freshDraftReady = atom(false)
export const $busy = atom(false)
export const $awaitingResponse = atom(false)
// Stored-session id whose most recent resume FAILED terminally (the gateway RPC
// rejected AND the REST transcript fallback also failed), leaving the window
// with no runtime and an empty transcript. Drives use-route-resume's self-heal:
// while this matches the routed session the loader would otherwise latch
// forever (messagesEmpty && !activeSessionId), so the hook re-attempts the
// resume on the next render/focus/reconnect instead of stranding the window.
// Null whenever the active route has a healthy (or in-flight) resume.
export const $resumeFailedSessionId = atom<string | null>(null)
// Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the
// terminal-failure latch above kept failing through all MAX_RESUME_RETRIES
// attempts). Distinct from $resumeFailedSessionId, which is armed *during* the
// backoff window too: this fires only once auto-recovery has given up, so the
// chat view can swap the perpetual loader for an explicit error + manual Retry
// affordance. A fresh resumeSession() (manual Retry, reconnect, reselect)
// clears it and resets the retry counter. Null whenever the active route has a
// healthy, in-flight, or still-auto-retrying resume.
export const $resumeExhaustedSessionId = atom<string | null>(null)
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
@ -262,6 +279,8 @@ export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($
export const setSelectedStoredSessionId = (next: Updater<string | null>) => updateAtom($selectedStoredSessionId, next)
export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($messages, next)
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setResumeFailedSessionId = (next: Updater<string | null>) => updateAtom($resumeFailedSessionId, next)
export const setResumeExhaustedSessionId = (next: Updater<string | null>) => updateAtom($resumeExhaustedSessionId, next)
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)

View file

@ -41,7 +41,7 @@ vi.mock('@/hermes', () => ({
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
}))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply, reportBackendContract } = await import('./updates')
const { setConnection } = await import('./session')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
@ -95,6 +95,61 @@ describe('maybeNotifyUpdateAvailable', () => {
})
})
describe('reportBackendContract', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
dismissSpy.mockClear()
vi.useRealTimers()
})
it('dismisses the toast when the backend meets the contract', () => {
reportBackendContract(2)
expect(dismissSpy).toHaveBeenCalledWith('backend-contract-skew')
expect(notifySpy).not.toHaveBeenCalled()
})
it('warns when the backend is behind (or reports no contract)', () => {
reportBackendContract(undefined)
expect(notifySpy).toHaveBeenCalledTimes(1)
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(2)
})
it('stays quiet on later session opens once the user closed it', () => {
reportBackendContract(1)
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// Opening another pre-existing session re-runs the check within cooldown.
reportBackendContract(1)
expect(notifySpy).not.toHaveBeenCalled()
})
it('reminds again after the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
reportBackendContract(1)
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('clears the snooze once the backend catches up, so a regression warns again', () => {
reportBackendContract(1)
lastToast().onDismiss()
notifySpy.mockClear()
reportBackendContract(2) // backend updated → satisfied, snooze cleared
reportBackendContract(1) // a later regression must warn immediately
expect(notifySpy).toHaveBeenCalledTimes(1)
})
})
describe('checkBackendUpdates', () => {
beforeEach(() => {
storage.clear()

View file

@ -91,26 +91,60 @@ function isUpdateToastSnoozed(): boolean {
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
// right after they closed it. Mirror the update toast: persist a cooldown when
// the user dismisses it. It still reminds again after the window if the backend
// is still behind, and clears immediately once the backend catches up.
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeSkewToast(): void {
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
}
function isSkewToastSnoozed(): boolean {
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/**
* Guard against a desktop GUI talking to a backend that predates its contract
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
* cryptically downstream, surface a persistent warning with a one-click align
* that runs the normal update flow (which self-heals to the right branch).
* cryptically downstream, surface a warning with a one-click align that runs
* the normal update flow (which self-heals to the right branch).
*
* Runs on every session open; closing the toast snoozes it for a cooldown so it
* doesn't nag on every thread switch.
*/
export function reportBackendContract(contract: number | undefined): void {
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
dismissNotification(SKEW_TOAST_ID)
// Backend caught up — forget any prior snooze so a future regression warns
// immediately rather than staying silent for the rest of the window.
persistString(SKEW_TOAST_SNOOZE_KEY, null)
return
}
if (isSkewToastSnoozed()) {
return
}
notify({
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
action: {
label: translateNow('notifications.updateHermes'),
onClick: () => {
snoozeSkewToast()
void applyBackendUpdate()
}
},
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'),
onDismiss: () => snoozeSkewToast(),
title: translateNow('notifications.backendOutOfDateTitle')
})
}

9
cli.py
View file

@ -12024,6 +12024,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Create the input area with multiline (Alt+Enter), autocomplete, and paste handling
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import ThreadedCompleter
_completer = SlashCommandCompleter(
@ -12039,7 +12040,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
wrap_lines=True,
read_only=Condition(lambda: bool(cli_ref._command_running)),
history=FileHistory(str(self._history_file)),
completer=_completer,
# complete_while_typing fires the completer on every keystroke. The
# completer does blocking work — fuzzy @-file indexing shells out to
# rg/fd (up to a 2s timeout) and path completion hits os.listdir/stat
# — so running it inline would stall the render loop on each key (very
# noticeable on WSL2/slow filesystems). ThreadedCompleter moves it off
# the UI event loop, keeping typing responsive.
completer=ThreadedCompleter(_completer),
complete_while_typing=True,
auto_suggest=SlashCommandAutoSuggest(
history_suggest=AutoSuggestFromHistory(),

View file

@ -28,14 +28,13 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
# arbitrary host UID (the classic `--user $(id -u):$(id -g)` invocation people
# used in the tini era to make container-written files match their host user).
#
# Under s6-overlay this no longer works: the bootstrap (UID remap, volume +
# build-tree chown, config seeding) all require root, and they're skipped when
# the container starts non-root. The baked image trees (/opt/data, /opt/hermes/
# .venv, ui-tui, node_modules) stay owned by the hermes build UID (10000), so an
# arbitrary `--user` UID can't write them — the runtime then fails with EACCES
# on a bind mount, or hard-crashes on a named volume (Docker initialises the
# volume from the image as UID 10000, and the non-root start can't even `cd`
# into $HERMES_HOME). See #34837 for the supervision-tree side of this.
# Under s6-overlay this no longer works: the bootstrap (UID remap, data-volume
# ownership, config seeding) requires root, and it is skipped when the container
# starts non-root. The baked install tree under /opt/hermes is intentionally
# root-owned and non-writable; mutable runtime state must live under
# $HERMES_HOME. An arbitrary `--user` UID therefore cannot repair or populate
# the data volume, and startup fails with EACCES. See #34837 for the
# supervision-tree side of this.
#
# The supported way to match host-side ownership is to start as root (the image
# default) and pass HERMES_UID/HERMES_GID — or the PUID/PGID aliases — which the
@ -53,9 +52,10 @@ if [ "$cur_uid" != 0 ] && [ "$cur_uid" != "$(id -u hermes)" ]; then
[stage2] ERROR: container started with --user $cur_uid (an arbitrary, non-hermes UID).
This is not supported under the s6-overlay image. The container bootstrap
(UID remap, volume ownership, dependency installs) needs to start as root,
and the baked image directories are owned by the hermes user (UID $(id -u hermes)),
so a pinned --user UID cannot write them — startup will fail.
(UID remap, data-volume ownership, config seeding) needs to start as root,
and the baked /opt/hermes install tree is intentionally root-owned and
non-writable, so a pinned --user UID cannot repair startup state — startup
will fail.
To make container-written files match your HOST user, DON'T use --user.
Start the container as root (the default) and pass your host UID/GID instead:
@ -207,49 +207,13 @@ if [ "$needs_chown" = true ]; then
done
fi
# --- Fix ownership of build trees under $INSTALL_DIR ---
# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the
# runtime hermes UID no longer owns them — otherwise:
# - .venv: lazy_deps.py cannot install platform packages (discord.py,
# telegram, slack, etc.) with EACCES (#15012, #21100)
# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when
# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD
# is set) and writes to ui-tui/dist/. Without this chown the new
# hermes UID can't write the build output (#28851).
# - gateway: Python writes __pycache__ and runtime artifacts beneath the
# gateway package on first import. After a UID remap those source-owned
# paths still belong to the build-time UID (10000) unless repaired here,
# producing EACCES for the supervised gateway (#27221).
# - node_modules: root-level dependencies (puppeteer, web tooling)
# that runtime code may walk/update.
# The set mirrors the build-time `chown -R hermes:hermes` line in the
# Dockerfile — keep them in sync if the Dockerfile chown set changes.
# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount
# concern doesn't apply — recursive is fine.
#
# This MUST be gated independently of the $HERMES_HOME ownership check
# above. `usermod -u <new> hermes` re-chowns the hermes home dir
# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a
# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new
# UID and `needs_chown` is false — but the build trees under /opt/hermes
# are NOT touched by usermod and remain owned by the build-time UID
# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently
# skipped this chown on the common PUID/NAS path, regressing lazy installs
# and TUI rebuilds. Probe the build trees directly instead: chown only
# when the venv is not already owned by the runtime hermes UID. Idempotent
# and skips the expensive recursive chown on every restart once ownership
# is settled.
venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "")
if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)"
chown -R hermes:hermes \
"$INSTALL_DIR/.venv" \
"$INSTALL_DIR/ui-tui" \
"$INSTALL_DIR/gateway" \
"$INSTALL_DIR/node_modules" \
2>/dev/null || \
echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing"
fi
# --- Immutable install tree ---
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
# hermes user. Hosted/container instances keep mutable state under
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
# non-writable prevents an agent session from self-modifying the installed
# source, venv, TUI bundle, or node_modules and bricking the gateway.
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands
@ -327,13 +291,25 @@ as_hermes mkdir -p \
"$HERMES_HOME/pairing" \
"$HERMES_HOME/platforms/pairing"
# --- Install-method stamp (read by detect_install_method() in hermes status) ---
# Preserved from the tini-era entrypoint (PR #27843). Must be written as
# the hermes user so ownership matches the file's documented owner.
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
# same shell-metacharacter safety described above.
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|| true
# --- Install-method stamp ---
# The 'docker' stamp is baked into the immutable install tree at
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
#
# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
# install's marker, so its in-app updater read 'docker' and refused to run
# 'hermes update'. To heal homes already poisoned by older images, remove a
# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
# own installer re-creates its code-scoped stamp; a genuine container relies on
# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
if [ -f "$HERMES_HOME/.install_method" ]; then
stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
if [ "$stamped" = "docker" ]; then
rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
fi
fi
# --- Seed config files (only on first boot) ---
seed_one() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View file

@ -0,0 +1,260 @@
# Relay ↔ Connector Contract (v1, EXPERIMENTAL)
> **Status:** EXPERIMENTAL. This contract MAY CHANGE without a deprecation
> cycle until at least two real Class-1 platforms (Discord + Telegram) have
> validated it. Evolution during the experimental phase is **additive-only**,
> gated by `contract_version`. A breaking change updates both repos in lockstep.
This document is the formal interface between the **Hermes gateway** (Python,
`gateway/relay/`) and the **connector** (Node/TypeScript,
`NousResearch/gateway-gateway`). The connector implementer's first action is to
read this file.
The gateway runs a generic `RelayAdapter` that dials **out** to the connector,
receives a `CapabilityDescriptor` at handshake, then exchanges normalized
`MessageEvent`s (inbound) and actions (outbound) over a per-turn bidirectional
WebSocket. The gateway never learns which concrete platform is fronting it; the
connector owns all platform-specific socket/identity logic.
---
## 1. Handshake
1. Gateway opens the transport (`connect`).
2. Gateway calls `handshake()`; connector returns a `CapabilityDescriptor`
(section 2) describing the platform this adapter instance fronts.
3. Gateway configures the adapter from the descriptor (char limit, length unit,
draft/edit/thread/markdown capabilities) and registers an inbound handler.
4. Connector then streams inbound events and accepts outbound actions.
`contract_version` (currently `1`) is carried in the descriptor. The gateway
ignores unknown descriptor fields (forward-compat) and fills missing optional
fields from defaults.
---
## 2. CapabilityDescriptor (handshake payload)
JSON object. Source of truth: `gateway/relay/descriptor.py`.
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `contract_version` | int | yes | Contract version (additive-only within a version). |
| `platform` | string | yes | Platform name (e.g. `"discord"`, `"telegram"`). |
| `label` | string | yes | Human-readable label. |
| `max_message_length` | int | yes | Char limit; gateway exposes as `MAX_MESSAGE_LENGTH`. 0 → treat as 4096. |
| `supports_draft_streaming` | bool | yes | Native draft-streaming preview support. |
| `supports_edit` | bool | yes | Edit-based streaming possible; if false, consumer degrades to one-message-per-segment. |
| `supports_threads` | bool | yes | `create_handoff_thread` capability. |
| `markdown_dialect` | string | yes | `"plain"`, `"markdown_v2"`, `"discord"`, … (drives `supports_code_blocks`). |
| `len_unit` | string | yes | `"chars"` (builtin len) or `"utf16"` (Telegram UTF-16 code units). |
| `emoji` | string | no | Display emoji (default 🔌). |
| `platform_hint` | string | no | System-prompt platform hint. |
| `pii_safe` | bool | no | Redact PII in session descriptions. |
Most fields are a projection of the gateway's existing `PlatformEntry`; the
runtime-only fields (`len_unit`, `supports_*`, `markdown_dialect`) come from the
live platform adapter's capability methods.
---
## 3. Inbound: `MessageEvent` envelope
The connector normalizes each platform wire event into a `MessageEvent`
(`gateway/platforms/base.py`) and delivers it to the gateway. **Inbound is
delivered over a signed HTTP POST, not the outbound `/relay` WebSocket** (see
the transport note below). The gateway keys the session via `build_session_key()`
from the embedded `SessionSource` — so populating the right discriminators is
the single highest-correctness responsibility of the connector.
### Inbound transport (signed HTTP POST, not the outbound WS)
The gateway dials **out** to the connector's `/relay` WebSocket for the
handshake + outbound actions (§4) + its own `/stop` egress (§5). Inbound,
however, is delivered the other way: the connector **POSTs** the normalized
event to the gateway's inbound endpoint (`HttpGatewayDelivery` on the connector;
`gateway/relay/inbound_receiver.py` on the gateway). The reason is
multi-instance: the connector instance that owns a platform's socket (and thus
produces inbound events) is generally **not** the instance a given gateway
dialed its outbound WS into, so inbound must target a tenant **endpoint** (which
may load-balance across gateway instances) rather than ride one gateway's
outbound socket. Each delivery is HMAC-signed with the per-tenant **delivery
key** (§6.1); the gateway verifies the signature over the exact raw bytes before
accepting the event. Two POST targets:
- `POST {gatewayEndpoint}``{"type":"message", "event": <MessageEvent>}`
- `POST {gatewayEndpoint}/interrupt``{"type":"interrupt", "session_key", "reason"?}` (§5)
> An earlier draft of this contract delivered inbound over the WS `inbound`
> frame. That only works single-instance and predates the multi-instance
> socket-ownership + channel-auth model; the signed-HTTP path above is the
> shipped design.
### SessionSource fields (the wire surface)
Source of truth: `SessionSource.to_dict()` in `gateway/session.py`. These are
every key the gateway accepts on the wire. `platform`, `chat_id`, `chat_type`,
`user_id`, `user_name`, `thread_id`, `chat_name`, and `chat_topic` are always
present (may be `null`); the rest are included only when set.
| Field | Type | Always sent | Meaning |
| --- | --- | --- | --- |
| `platform` | string | yes | Platform name (matches the descriptor's `platform`). |
| `chat_id` | string | yes | Primary conversation id (channel/chat). Session-key discriminator. |
| `chat_type` | string | yes | `dm` / `group` / `channel` / `thread` / `forum`. |
| `chat_name` | string\|null | yes | Human-readable chat name. |
| `user_id` | string\|null | yes | Message author id. Session-key discriminator. |
| `user_name` | string\|null | yes | Author display name. |
| `thread_id` | string\|null | yes | Thread/forum-topic id when in a thread. Session-key discriminator. |
| `chat_topic` | string\|null | yes | Channel topic/description (Discord, Slack). |
| `user_id_alt` | string | no | Platform-specific stable alt id (Signal UUID, Feishu union_id). |
| `chat_id_alt` | string | no | Alternate chat id (e.g. Signal group internal id). |
| `guild_id` | string | no | Discord guild / Slack workspace / Matrix server scope. **REQUIRED for Discord server isolation.** Session-key discriminator. |
| `parent_chat_id` | string | no | Parent channel when `chat_id` refers to a thread. |
| `message_id` | string | no | Id of the triggering message (for pin/reply/react). |
> `is_bot` (author-is-a-bot/webhook classification) exists on the gateway-side
> dataclass but is **intentionally NOT on the wire** in v1 — it is not part of
> `to_dict()`. Do not add it to the connector's `SessionSource` until it is
> first added here and to `to_dict()` (additive bump).
### SessionSource discriminators per platform
| Platform | chat_id | chat_type | user_id | thread_id | guild_id |
| --- | --- | --- | --- | --- | --- |
| **Discord** | channel id | `dm`/`group`/`thread` | author id | thread channel id (threads) | **guild id** (REQUIRED for server isolation) |
| **Telegram** | chat id | `dm`/`group`/`forum` | from id | forum topic id (forums) | — |
**Get Discord's `guild_id` wrong and two servers collide into one session.**
This is the #1 High-severity risk. The gateway's `build_session_key()` is the
conformance oracle: for a given `SessionSource`, the connector's normalization
must produce the same key the Python adapter would. (The Phase-1 stub tests
assert known-input → known-key.)
### Bot identity vs tenant (single-bot consolidation, Appendix A)
The envelope carries the **originating bot identity** as a field **distinct from
tenant**. Tenant is resolved from the event's own discriminator (Discord
`guild_id`, Telegram `chat_id`, webhook path/subdomain) — **never** from which
token/socket/process delivered it. This keeps one shared bot able to front many
tenants (Phase 6) without overloading an existing field.
---
## 4. Outbound: action set
The gateway calls the transport with action dicts. Source of truth:
`gateway/relay/transport.py` + `gateway/relay/adapter.py`.
| `op` | Fields | Result |
| --- | --- | --- |
| `send` | `chat_id`, `content`, `reply_to?`, `metadata?` | `{success: bool, message_id?, error?}` |
| `edit` | `chat_id`, `message_id`, `content`, `metadata?` | `{success: bool, error?}` |
| `typing` | `chat_id` | `{success: bool}` |
| `follow_up` | `session_key`, `kind`, `content`, `metadata?` | `{success: bool, message_id?, error?}` |
`get_chat_info(chat_id)` is a separate proxied call returning at least
`{name, type}`. Media actions follow the same envelope shape (deferred to a
later contract revision; additive).
**`follow_up` (A2 capability action).** Some inbound payloads carry a credential
that acts on the **shared** bot identity (e.g. a Discord interaction follow-up
token). Per §6 the connector strips that at the edge and binds it in its
capability vault keyed by the session; it **never reaches the gateway**. To use
it, the gateway issues `follow_up` naming the **session it is already in**
(`session_key`) plus the capability `kind` (e.g. `discord.interaction_token`) —
**never a token**. The connector resolves the real value from its vault,
enforces the tenant match (tenant B can never wield tenant A's capability), and
egresses. `success: false` when the capability is absent/expired or the tenant
doesn't match — the gateway has nothing to retry with, by design (a leaked
gateway holds zero capability material). Source of truth:
`gateway/relay/transport.py` (`send_follow_up`) + `gateway/relay/adapter.py`.
---
## 5. Interrupt (`/stop`) routing
- **Gateway → connector:** `send_interrupt(session_key, reason?)` egresses a
mid-turn `/stop` over the outbound WS. The connector MUST forward it to the
gateway instance running that `session_key` (the routing invariant).
- **Connector → gateway:** an inbound interrupt for a `session_key` is delivered
as a **signed HTTP POST** to `{gatewayEndpoint}/interrupt` (§3 transport note),
and bridged by the adapter's `on_interrupt(session_key, chat_id)` into the
existing per-session interrupt mechanism, cancelling exactly that turn
(siblings untouched).
The gateway→connector `/stop` rides the outbound WS; the connector→gateway
interrupt rides the same signed-HTTP inbound path as a normalized event.
---
## 6. Trust boundary & signed-body handling (A2)
**The connector is the sole crypto/identity boundary. The gateway re-validates
nothing.**
Webhook signatures (Discord ed25519, Twilio HMAC, WeCom BizMsgCrypt) are
computed over exact raw bytes, and some payloads are *encrypted* with a shared
secret. The connector fronts a **shared** bot for many tenants and holds every
tenant's platform secrets, so it:
- **verifies / decrypts at the edge** (the only place the secrets live),
- **normalizes** the payload into a tenant-scoped `MessageEvent` (§3),
- **strips any shared-identity capability** out of the payload and binds it in
its capability vault, keyed by the session (see §4 `follow_up`),
- **forwards only the sanitized `MessageEvent`** — never the raw signed body.
The gateway therefore performs **no** platform signature/crypto verification on
the relay path; it trusts the normalized event. This is an enforced invariant on
the gateway side (`tests/gateway/relay/test_relay_sheds_crypto.py`: the relay
package imports/calls no platform-crypto).
**Why not "forward the signed body byte-for-byte so the gateway re-validates"?**
That earlier model is incoherent under an untrusted, disposable tenant gateway:
- Re-validating Twilio HMAC / WeCom crypto would require handing the gateway the
**shared signing secret** — which is itself the leak, and on a shared bot it's
a *cross-tenant* leak.
- WeCom payloads are encrypted with the shared secret; the connector must decrypt
at the edge just to route, so forwarding ciphertext would again require giving
the gateway the secret.
- A Discord interaction token lives **inside** the signed JSON body — you cannot
both preserve the bytes and strip the credential; they are the same bytes.
So byte-preservation is abandoned deliberately: the connector re-serializes the
sanitized event and the gateway trusts it. This also unifies the passthrough and
relay planes — both are "verify at the edge → emit a normalized event," differing
only in transport. See `docs/capability-trust-boundary.md` (connector repo:
`gateway-gateway`) for the full A2 rationale and the connector-side vault.
### 6.1 Channel authentication (the connector⇄gateway link itself)
A2 makes the connector the sole holder of platform secrets while the gateway may
be **customer-managed and internet-exposed**, so the connector⇄gateway channel
is itself authenticated. The gateway holds two enrollment-issued credentials
(`hermes gateway enroll` → connector `/relay/enroll`): a **per-gateway secret**
and a **per-tenant delivery key**. Both are HMAC-SHA256 schemes with a
multi-secret rotation verify list (gateway side: `gateway/relay/auth.py`;
connector side: `src/core/relayAuthToken.ts` + `src/core/deliverySigning.ts`).
| Leg | Credential | Mechanism |
|-----|-----------|-----------|
| Gateway → connector WS upgrade | per-gateway secret | An `Authorization` bearer header on the `/relay` upgrade. The token is `base64url(payload:exp:sig)` where `payload = gatewayId` and `sig = HMAC(payload:exp, secret)`. Connector verifies and rejects the upgrade (**close 4401**) on mismatch/absence/revocation. The authenticated tenant comes from the connector's store, never the `hello` frame. |
| Connector → gateway inbound POST | per-tenant delivery key | Two headers: `x-relay-timestamp` (unix seconds) and `x-relay-signature` (hex `HMAC(ts.rawBody, deliveryKey)`). Gateway verifies over the **exact raw bytes** within a ±300s replay window before accepting the event; rejects **401** otherwise. |
This is the **channel** authenticator — distinct from platform crypto, which the
relay path still sheds entirely (§6). The gateway holds zero platform secrets;
these two keys authenticate only the connector link. Full threat model +
enrollment/rotation/kill-switch design: `docs/connector-gateway-auth-design.md`
(connector repo).
---
## 7. Versioning policy
- `contract_version` is an int; bump **only** for additive changes during the
experimental phase (new optional fields, new `op`s).
- A breaking change (renamed/removed field, changed semantics) requires a
coordinated update of both repos and a version bump.
- The connector's first PR references the commit SHA of this file it implements
against.

View file

@ -164,6 +164,7 @@ class Platform(Enum):
BLUEBUBBLES = "bluebubbles"
QQBOT = "qqbot"
YUANBAO = "yuanbao"
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
@classmethod
def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters.
@ -492,6 +493,13 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
),
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
# is negotiated at handshake time, so the URL is the only config-level
# signal in the experimental phase. EXPERIMENTAL — may change.
Platform.RELAY: lambda cfg: bool(
cfg.extra.get("relay_url") or cfg.extra.get("url")
),
}

View file

@ -0,0 +1,166 @@
"""Helpers for rendering gateway message timestamps exactly once.
Gateway messages need timestamps in the LLM context for temporal awareness, but
persisted message content should stay clean so replay does not accumulate
``[timestamp] [timestamp] ...`` prefixes across turns.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable timestamp format. Returns ``None`` when the value
cannot be interpreted.
"""
if ts_value is None:
return None
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
try:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
parsed = _parse_timestamp_prefix(text, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
epoch = coerce_message_timestamp(ts_value, tz=tz)
if epoch is None:
return ""
if tz is not None:
dt = datetime.fromtimestamp(epoch, tz=tz)
else:
dt = datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
are present, the timestamp closest to the actual message text wins. That
preserves the original platform-send time for legacy contaminated rows like
``[processing time] [platform time] [sender] message``.
"""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while True:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
break
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
text = text[match.end():]
return text, embedded_epoch
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
Existing leading timestamp prefixes are removed first. If such a prefix was
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
formatted and prepended. If no timestamp is available, the cleaned content is
returned unchanged.
"""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
if clean_content:
return f"{prefix} {clean_content}"
return prefix
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
return None
return _parse_timestamp_match(match, tz=tz)
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if "iso" in match.groupdict() and match.group("iso"):
iso_text = match.group("iso")
try:
dt = datetime.fromisoformat(iso_text)
except ValueError:
try:
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
date_part = match.group("date")
time_part = match.group("time")
try:
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())

View file

@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter):
message_id = (msg.get("result") or {}).get("message_id")
else:
message_id = getattr(msg, "message_id", None)
if message_id is not None:
# Telegram won't echo rich content in reply_to_message, so remember
# what we sent — replies to this message resolve via this index.
try:
from gateway import rich_sent_store
rich_sent_store.record(str(chat_id), str(message_id), content)
except Exception:
pass
return SendResult(
success=True,
message_id=str(message_id) if message_id is not None else None,
@ -6700,6 +6708,19 @@ class TelegramAdapter(BasePlatformAdapter):
or message.reply_to_message.caption
or None
)
if not reply_to_text:
# Rich messages (sendRichMessage — the launchd briefings and
# the gateway's own rich finals) are NOT echoed with their
# content in reply_to_message; Telegram sends no text,
# caption, or api_kwargs for them. Recover the text we sent
# from our local send-time index, keyed by message id.
try:
from gateway import rich_sent_store
reply_to_text = rich_sent_store.lookup(
str(chat.id), reply_to_id
)
except Exception:
reply_to_text = None
# Per-channel/topic ephemeral prompt
from gateway.platforms.base import resolve_channel_prompt

398
gateway/relay/__init__.py Normal file
View file

@ -0,0 +1,398 @@
"""Relay/connector support package for the Hermes gateway.
EXPERIMENTAL. This package implements the gateway side of the "Gateway Gateway"
relay design: a generic ``RelayAdapter`` plus the wire-serializable
``CapabilityDescriptor`` the connector hands it at handshake time, and the
production ``WebSocketRelayTransport`` that dials the connector. The public API
(module names, descriptor field set, transport protocol) MAY CHANGE without a
deprecation cycle until at least two real Class-1 platforms (Discord + Telegram)
have shaken out the schema.
See ``docs/relay-connector-contract.md`` for the formal cross-repo interface.
Activation is driven by configuration, not a separate feature flag: the relay
platform is registered when a connector relay URL is configured
(``GATEWAY_RELAY_URL`` env or ``gateway.relay_url`` in config.yaml). Deployments
that don't set it are unaffected — exactly the same shape as ``gateway.proxy_url``.
"""
from __future__ import annotations
import os
from typing import Optional
def relay_url() -> Optional[str]:
"""The connector relay endpoint URL, or None when relay is not configured.
Checks ``GATEWAY_RELAY_URL`` (convenient for Docker) first, then
``gateway.relay_url`` in config.yaml. A non-empty value activates the relay
platform; absence means a normal direct/single-tenant gateway.
"""
url = os.environ.get("GATEWAY_RELAY_URL", "").strip()
if url:
return url.rstrip("/")
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = _load_gateway_config()
url = (cfg.get("gateway") or {}).get("relay_url", "").strip()
if url:
return url.rstrip("/")
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return None
def relay_platform_identity() -> tuple[str, str]:
"""Platform + bot id this gateway fronts over the relay (for the handshake hello).
Defaults to ``("relay", "")``; overridable via ``GATEWAY_RELAY_PLATFORM`` /
``GATEWAY_RELAY_BOT_ID`` so one connector can front several platforms.
"""
platform = os.environ.get("GATEWAY_RELAY_PLATFORM", "relay").strip() or "relay"
bot_id = os.environ.get("GATEWAY_RELAY_BOT_ID", "").strip()
return platform, bot_id
def relay_connection_auth() -> tuple[Optional[str], Optional[str]]:
"""The (gateway_id, upgrade_secret) this gateway authenticates the WS upgrade with.
Both come from enrollment (``hermes gateway enroll`` writes them to
``~/.hermes/.env``): ``GATEWAY_RELAY_ID`` identifies the enrolled instance,
``GATEWAY_RELAY_SECRET`` is the per-gateway signing secret. Either absent ->
``(None, None)`` and the transport dials unauthenticated (dev/test, or a
connector that doesn't enforce auth). Checks env first (Docker), then
``gateway.relay_id`` / ``gateway.relay_secret`` in config.yaml.
"""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip()
secret = os.environ.get("GATEWAY_RELAY_SECRET", "").strip()
if not (gateway_id and secret):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
gateway_id = gateway_id or str(cfg.get("relay_id", "") or "").strip()
secret = secret or str(cfg.get("relay_secret", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
return (gateway_id or None, secret or None)
def relay_inbound_config() -> tuple[Optional[str], Optional[str], int]:
"""Resolve (delivery_key, bind_host, bind_port) for the inbound receiver.
The connector delivers normalized inbound events to this gateway over a
SIGNED HTTP POST (not the outbound WS), verified with the per-tenant delivery
key issued at enrollment (``GATEWAY_RELAY_DELIVERY_KEY``). The receiver only
starts when a delivery key AND a bind port are configured a gateway with no
public inbound URL (e.g. a purely outbound dev run) simply doesn't run it.
Env first (Docker), then ``gateway.relay_delivery_key`` /
``gateway.relay_inbound_host`` / ``gateway.relay_inbound_port`` in config.yaml.
Port 0 (default/unset) -> receiver disabled.
"""
key = os.environ.get("GATEWAY_RELAY_DELIVERY_KEY", "").strip()
host = os.environ.get("GATEWAY_RELAY_INBOUND_HOST", "").strip()
port_raw = os.environ.get("GATEWAY_RELAY_INBOUND_PORT", "").strip()
if not (key and port_raw):
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
key = key or str(cfg.get("relay_delivery_key", "") or "").strip()
host = host or str(cfg.get("relay_inbound_host", "") or "").strip()
if not port_raw:
port_raw = str(cfg.get("relay_inbound_port", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash registration
pass
try:
port = int(port_raw) if port_raw else 0
except ValueError:
port = 0
return (key or None, host or "0.0.0.0", port)
def relay_endpoint() -> Optional[str]:
"""The gateway's own PUBLIC inbound URL, asserted to the connector at provision.
The connector delivers signed inbound POSTs to this URL and stores it on the
tenant's route rows. It is gateway-asserted (the connector scopes it to the
verified tenant, so a dishonest gateway can only misdirect its OWN inbound).
The *source* of the value differs by deployment but the code path is uniform:
a self-hosted operator sets ``GATEWAY_RELAY_ENDPOINT`` (mirrors how they set
``HERMES_DASHBOARD_PUBLIC_URL``); a hosted/NAS container has the same var
stamped in (NAS knows the public URL only in that case). Absent -> the
gateway provisions outbound-only (no inbound routes written).
Env first (Docker), then ``gateway.relay_endpoint`` in config.yaml.
"""
url = os.environ.get("GATEWAY_RELAY_ENDPOINT", "").strip()
if not url:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
url = str(cfg.get("relay_endpoint", "") or "").strip()
except Exception: # noqa: BLE001 - config absence/parse must never crash boot
url = ""
return url.rstrip("/") or None
def relay_route_keys() -> list[str]:
"""Discriminators (guild_ids / chat_ids / paths) this gateway's tenant owns.
Gateway-provided config, paired with ``relay_endpoint()``: the connector
writes one route row per (routeKey -> tenant, endpoint), so route keys only
take effect alongside an endpoint. Empty -> outbound-only provisioning (the
connector accepts an empty set and writes no route rows).
``GATEWAY_RELAY_ROUTE_KEYS`` is comma-separated; config.yaml
``gateway.relay_route_keys`` may be a list or a comma string.
"""
raw = os.environ.get("GATEWAY_RELAY_ROUTE_KEYS", "").strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
val = cfg.get("relay_route_keys", "")
if isinstance(val, (list, tuple)):
return [str(k).strip() for k in val if str(k).strip()]
raw = str(val or "").strip()
except Exception: # noqa: BLE001
raw = ""
return [k.strip() for k in raw.split(",") if k.strip()]
def _provision_url(relay_dial_url: str) -> str:
"""Map the ``ws(s)://…/relay`` dial URL to the ``http(s)://…/relay/provision`` POST URL."""
raw = relay_dial_url.rstrip("/")
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return f"{raw}/relay/provision"
def _post_provision(
*,
provision_url: str,
access_token: str,
gateway_id: str,
platform: str,
bot_id: str,
gateway_endpoint: Optional[str],
route_keys: list[str],
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/provision`` and return the JSON body.
The connector validates ``access_token`` against NAS, derives the
authoritative tenant, mints the per-gateway secret + per-tenant delivery key,
upserts the tenant's route rows, and returns
``{secret, deliveryKey, tenant, gatewayId, routeKeys}``. Raises RuntimeError
with a user-facing message on any non-2xx / transport failure.
"""
import json
import urllib.error
import urllib.request
body: dict = {
"gatewayId": gateway_id,
"platform": platform,
"botId": bot_id,
"gatewayEndpoint": gateway_endpoint or "",
"routeKeys": route_keys,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
provision_url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
raise RuntimeError(
f"connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"could not reach connector: {exc.reason}") from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("connector returned an unexpected response (no secret)")
return payload
def self_provision_if_managed() -> bool:
"""Managed-boot self-provision: mint relay creds in-process, no human, no disk.
Fires only on a MANAGED boot (``is_managed()``) with relay configured
(``relay_url()`` set) and NO per-gateway secret already present. In that case
the runtime resolves the agent's own Nous access token (the same
``resolve_nous_access_token()`` the enroll CLI / dashboard register use),
POSTs ``/relay/provision`` asserting its own endpoint + route keys, and sets
``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` / ``GATEWAY_RELAY_DELIVERY_KEY``
into ``os.environ`` so the subsequent ``register_relay_adapter()`` picks them
up. The creds live ONLY in process memory never written to ``~/.hermes/.env``
(``save_env_value`` refuses under managed anyway, and keeping the secret off
any volume is the stronger posture).
Stateless: process-env creds don't survive a restart, so a managed container
re-provisions every boot; the connector's rotation window covers a still-
connected prior instance. An explicitly-pinned ``GATEWAY_RELAY_SECRET`` (env
or config) is RESPECTED self-provision skips so an operator pin isn't
stomped.
Returns True if it provisioned, False otherwise. NEVER raises: a provision
failure logs and returns False so the gateway still boots (and
``register_relay_adapter`` will simply dial unauthenticated / be rejected,
rather than the whole gateway crashing).
"""
import logging
logger = logging.getLogger("gateway.relay")
try:
from hermes_cli.config import is_managed
except Exception: # noqa: BLE001
return False
if not is_managed():
return False
dial_url = relay_url()
if not dial_url:
return False
# Respect an already-present (pinned/stamped) secret — don't stomp it.
existing_id, existing_secret = relay_connection_auth()
if existing_id and existing_secret:
logger.info("relay self-provision skipped: GATEWAY_RELAY_SECRET already set")
return False
try:
from hermes_cli.auth import resolve_nous_access_token
access_token = resolve_nous_access_token()
except Exception as exc: # noqa: BLE001 - boot must survive a token failure
logger.warning("relay self-provision skipped: could not resolve Nous token (%s)", exc)
return False
platform, bot_id = relay_platform_identity()
# gatewayId default mirrors the enroll CLI's hostname-based slug.
import socket
try:
host = socket.gethostname().strip()
except Exception: # noqa: BLE001
host = ""
gateway_id = os.environ.get("GATEWAY_RELAY_ID", "").strip() or f"gw-{host or 'hermes'}"
endpoint = relay_endpoint()
route_keys = relay_route_keys()
try:
result = _post_provision(
provision_url=_provision_url(dial_url),
access_token=access_token,
gateway_id=gateway_id,
platform=platform,
bot_id=bot_id,
gateway_endpoint=endpoint,
route_keys=route_keys,
)
except RuntimeError as exc:
logger.warning("relay self-provision failed (%s); gateway will boot without relay auth", exc)
return False
# Set creds in-process so register_relay_adapter() + relay_inbound_config()
# read them from os.environ. Never logged.
os.environ["GATEWAY_RELAY_ID"] = str(result.get("gatewayId") or gateway_id)
os.environ["GATEWAY_RELAY_SECRET"] = str(result.get("secret") or "")
os.environ["GATEWAY_RELAY_DELIVERY_KEY"] = str(result.get("deliveryKey") or "")
tenant = str(result.get("tenant") or "")
logger.info(
"relay self-provisioned (gateway_id=%s tenant=%s routes=%d inbound=%s)",
os.environ["GATEWAY_RELAY_ID"],
tenant or "?",
len(route_keys),
"yes" if endpoint else "outbound-only",
)
return True
def register_relay_adapter(force: bool = False, url: Optional[str] = None) -> bool:
"""Register the generic ``relay`` platform via the platform registry.
Registers when a relay URL is configured (or ``force=True`` for tests, which
builds a transport-less adapter the unit-test posture). Returns True if
registration happened. Additive: uses the same registry path as plugin
adapters, so no core dispatch changes are needed.
When a URL is present the factory builds a live ``WebSocketRelayTransport``;
the ``RelayAdapter`` negotiates the real ``CapabilityDescriptor`` at
``connect()`` time via ``transport.handshake()``.
"""
resolved_url = url if url is not None else relay_url()
if not (force or resolved_url):
return False
from gateway.platform_registry import PlatformEntry, platform_registry
from gateway.relay.adapter import RelayAdapter
from gateway.relay.descriptor import CONTRACT_VERSION, CapabilityDescriptor
platform, bot_id = relay_platform_identity()
def _factory(config):
# Placeholder descriptor; replaced by the negotiated one at connect time
# when a transport is present. With no URL (force/test) the adapter is
# transport-less and keeps the placeholder.
placeholder = CapabilityDescriptor(
contract_version=CONTRACT_VERSION,
platform=platform,
label="Relay",
max_message_length=4096,
supports_draft_streaming=False,
supports_edit=True,
supports_threads=False,
markdown_dialect="plain",
len_unit="chars",
)
transport = None
if resolved_url:
from gateway.relay.ws_transport import WebSocketRelayTransport
gateway_id, upgrade_secret = relay_connection_auth()
transport = WebSocketRelayTransport(
resolved_url,
platform,
bot_id,
gateway_id=gateway_id,
upgrade_secret=upgrade_secret,
)
return RelayAdapter(config, placeholder, transport=transport)
platform_registry.register(
PlatformEntry(
name="relay",
label="Relay",
adapter_factory=_factory,
check_fn=lambda: True,
source="builtin",
emoji="\U0001f50c",
)
)
return True

220
gateway/relay/adapter.py Normal file
View file

@ -0,0 +1,220 @@
"""RelayAdapter — one generic gateway adapter fronted by the connector. EXPERIMENTAL.
A single ``BasePlatformAdapter`` subclass that, at handshake, receives a
``CapabilityDescriptor`` from the connector telling it which platform it is
fronting and which capabilities to advertise to the ``GatewayStreamConsumer``.
It implements the four abstract methods (``connect`` / ``disconnect`` / ``send``
/ ``get_chat_info``) plus the capability surface (``MAX_MESSAGE_LENGTH``,
``message_len_fn``, ``supports_draft_streaming``) by delegating wire I/O to an
injected transport and reading capabilities off the descriptor.
There is NO per-platform gateway code: the connector is the only side that knows
"this chat_id maps to a Discord channel, send it via the Discord websocket."
The gateway sees an ordinary ``MessageEvent`` in and calls ``adapter.send`` out.
EXPERIMENTAL: the transport protocol and descriptor schema may change without a
deprecation cycle until >=2 Class-1 platforms validate them.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Dict, Optional
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.transport import RelayTransport
logger = logging.getLogger(__name__)
def _utf16_len(text: str) -> int:
"""Count UTF-16 code units (Telegram's length unit)."""
return len(text.encode("utf-16-le")) // 2
# Table-driven length-unit selection from the descriptor's ``len_unit``.
_LEN_FNS: Dict[str, Callable[[str], int]] = {
"chars": len,
"utf16": _utf16_len,
}
class RelayAdapter(BasePlatformAdapter):
"""Generic relay adapter advertising a connector-negotiated capability profile."""
def __init__(
self,
config: PlatformConfig,
descriptor: CapabilityDescriptor,
transport: Optional[RelayTransport] = None,
) -> None:
# The relay adapter fronts many platforms but presents as a single
# logical platform to the runner; Platform.RELAY identifies it.
super().__init__(config, Platform.RELAY)
self.descriptor = descriptor
self._transport = transport
# Capability surface read by stream_consumer (getattr(..., 4096)).
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
# Inbound delivery receiver (signed connector→gateway HTTP POSTs). Built
# lazily in connect() when a delivery key + bind port are configured; a
# purely-outbound dev gateway runs without it. See inbound_receiver.py.
self._inbound_runner: Any = None
# ── capability surface (from descriptor) ─────────────────────────────
@property
def message_len_fn(self) -> Callable[[str], int]:
return _LEN_FNS.get(self.descriptor.len_unit, len)
def supports_draft_streaming(
self,
chat_type: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> bool:
return self.descriptor.supports_draft_streaming
# ── abstract methods (delegated to the transport) ────────────────────
async def connect(self) -> bool:
if self._transport is None:
raise RuntimeError("RelayAdapter has no transport configured")
self._transport.set_inbound_handler(self._on_inbound)
ok = await self._transport.connect()
if not ok:
return False
# Negotiate the real capability descriptor from the connector and adopt
# it — the placeholder passed at construction is replaced by what the
# connector advertises for the platform this gateway actually fronts.
try:
descriptor = await self._transport.handshake()
except Exception as exc: # noqa: BLE001 - a failed handshake = a failed connect
logger.warning("relay handshake failed: %s", exc)
return False
self._apply_descriptor(descriptor)
# Start the signed inbound-delivery receiver if configured (the connector
# POSTs normalized events to it over HTTP, verified with the tenant
# delivery key). Non-fatal: a receiver bind failure must not fail the
# outbound connection — the gateway can still send.
await self._maybe_start_inbound_receiver()
return True
async def _maybe_start_inbound_receiver(self) -> None:
"""Start the inbound HTTP receiver when a delivery key + port are set."""
from gateway.relay import relay_inbound_config
delivery_key, host, port = relay_inbound_config()
if not (delivery_key and port):
return # no inbound URL configured -> outbound-only gateway
try:
from aiohttp import web
from gateway.relay.inbound_receiver import InboundDeliveryReceiver
receiver = InboundDeliveryReceiver(
delivery_key_verify_list=lambda: [delivery_key],
on_message=self._on_inbound,
on_interrupt=self.on_interrupt,
)
runner = web.AppRunner(receiver.build_app(), access_log=None)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
self._inbound_runner = runner
logger.info("relay inbound receiver listening on http://%s:%s", host, port)
except Exception as exc: # noqa: BLE001 - inbound bind failure must not kill outbound
logger.warning("relay inbound receiver failed to start: %s", exc)
self._inbound_runner = None
def _apply_descriptor(self, descriptor: CapabilityDescriptor) -> None:
"""Adopt a (re)negotiated descriptor into the live capability surface."""
self.descriptor = descriptor
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
async def _on_inbound(self, event) -> None:
"""Bridge a connector-delivered MessageEvent into the normal adapter path."""
await self.handle_message(event)
async def on_interrupt(self, session_key: str, chat_id: str) -> None:
"""Bridge a connector-delivered /stop into the adapter's interrupt path.
The connector forwards a mid-turn interrupt down the socket owned by
the gateway instance running ``session_key``; this routes it to the
existing per-session interrupt mechanism (sets the
``_active_sessions[session_key]`` Event and clears typing), cancelling
the right turn without touching sibling sessions.
"""
await self.interrupt_session_activity(session_key, chat_id)
async def disconnect(self) -> None:
if self._inbound_runner is not None:
try:
await self._inbound_runner.cleanup()
except Exception: # noqa: BLE001 - best-effort teardown
pass
self._inbound_runner = None
if self._transport is not None:
await self._transport.disconnect()
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
if self._transport is None:
return SendResult(success=False, error="no transport")
result = await self._transport.send_outbound(
{
"op": "send",
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
"metadata": metadata or {},
}
)
return SendResult(
success=bool(result.get("success")),
message_id=result.get("message_id"),
error=result.get("error"),
)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
# Proxied to the connector (it owns the platform connection / cache).
if self._transport is None:
return {"name": chat_id, "type": "dm"}
return await self._transport.get_chat_info(chat_id)
async def send_follow_up(
self,
session_key: str,
kind: str,
content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send via a shared-identity capability bound to a session (A2 outbound).
The gateway never holds the credential: it names the session it is
already in plus the capability ``kind``, and the connector resolves the
real value from its vault and egresses (enforcing the tenant match). Used
e.g. to post a Discord interaction follow-up as the shared bot without
the token ever reaching the gateway. See RelayTransport.send_follow_up.
"""
if self._transport is None:
return SendResult(success=False, error="no transport")
result = await self._transport.send_follow_up(
{
"op": "follow_up",
"session_key": session_key,
"kind": kind,
"content": content,
"metadata": metadata or {},
}
)
return SendResult(
success=bool(result.get("success")),
message_id=result.get("message_id"),
error=result.get("error"),
)

168
gateway/relay/auth.py Normal file
View file

@ -0,0 +1,168 @@
"""Gateway-side relay authentication primitives. EXPERIMENTAL.
The connectorgateway channel is authenticated because a gateway may be
customer-managed and internet-exposed (see the connector repo
``docs/connector-gateway-auth-design.md``). This module is the **gateway half**
of two HMAC schemes whose wire bytes must match the connector's TypeScript
exactly:
1. **WS upgrade auth** (gateway connector): the gateway presents
``Authorization: Bearer <token>`` on the ``/relay`` WebSocket upgrade, where
``token = make_upgrade_token(gateway_id, secret)``. Mirrors the connector's
``relayAuthToken.ts`` ``makeToken`` (``src/core/relayAuthToken.ts``):
``base64url(f"{payload}:{exp}:{sig}")`` with
``sig = HMAC_SHA256(f"{payload}:{exp}", secret).hexdigest()`` and
``payload == gateway_id``.
2. **Inbound delivery signature** (connector gateway): the connector signs
each inbound POST with the per-tenant *delivery key*, carried as
``x-relay-timestamp`` + ``x-relay-signature`` headers; the gateway verifies
before accepting the event. Mirrors the connector's ``deliverySigning.ts``:
``sig = HMAC_SHA256(f"{ts}.{body_json}", key).hexdigest()`` over the EXACT
request body bytes, with a replay-window skew check.
Both schemes use a **multi-secret verify list** (primary first, then a secondary
during a rotation window), exactly like ``api/src/handlers/stats_oauth.ts`` so
a secret rotation doesn't invalidate outstanding tokens.
EXPERIMENTAL: may change without a deprecation cycle until 2 Class-1 platforms
validate the relay contract.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from typing import Optional, Sequence
# Header names the connector uses for inbound delivery signatures
# (connector ``src/core/deliverySigning.ts`` — DELIVERY_TS_HEADER / SIG_HEADER).
DELIVERY_TS_HEADER = "x-relay-timestamp"
DELIVERY_SIG_HEADER = "x-relay-signature"
# Default replay window for an inbound delivery signature (connector default).
_DEFAULT_MAX_SKEW_SECONDS = 300
# Default TTL for an upgrade token (connector ``makeUpgradeToken`` default).
_DEFAULT_UPGRADE_TTL_SECONDS = 300
def _hmac_hex(payload: str, secret: str) -> str:
"""HMAC-SHA256 hex digest of ``payload`` under ``secret`` (UTF-8)."""
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
def sign(payload: str, secret: str) -> str:
"""HMAC-SHA256 hex digest — the connector's ``sign`` (relayAuthToken.ts)."""
return _hmac_hex(payload, secret)
def verify_signature(payload: str, sig_hex: str, secrets: Sequence[str]) -> bool:
"""Constant-time check that ``sig_hex`` is a valid HMAC of ``payload`` under
ANY of ``secrets`` (rotation window). Length-mismatched candidates are
skipped without a timing leak. Mirrors ``verifySignature``.
"""
try:
sig_buf = bytes.fromhex(sig_hex)
except (ValueError, TypeError):
return False
if len(sig_buf) == 0:
return False
for secret in secrets:
if not secret:
continue
expected = bytes.fromhex(_hmac_hex(payload, secret))
if len(expected) != len(sig_buf):
continue
if hmac.compare_digest(sig_buf, expected):
return True
return False
def make_token(payload: str, secret: str, ttl_seconds: int = 0) -> str:
"""Build a signed, optionally-expiring token — the connector's ``makeToken``.
``base64url(f"{payload}:{exp}:{sig}")`` where ``exp`` is a unix-seconds
expiry (0 = never) and ``sig = HMAC_SHA256(f"{payload}:{exp}", secret)``.
base64url is unpadded to match Node's ``Buffer.toString("base64url")``.
"""
exp = int(time.time()) + ttl_seconds if ttl_seconds > 0 else 0
signed = f"{payload}:{exp}"
sig = _hmac_hex(signed, secret)
raw = f"{signed}:{sig}".encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def make_upgrade_token(
gateway_id: str, secret: str, ttl_seconds: int = _DEFAULT_UPGRADE_TTL_SECONDS
) -> str:
"""The WS-upgrade bearer token a gateway sends: ``payload = gateway_id``.
The connector peeks ``gateway_id`` (the payload head) to index its secret
verify list, then verifies the signature against that gateway's stored
secret(s). Mirrors the connector's ``makeUpgradeToken``.
"""
return make_token(gateway_id, secret, ttl_seconds)
def verify_token(token: str, secrets: Sequence[str]) -> Optional[str]:
"""Verify a token built by ``make_token``; return the payload or None.
Splits from the right so a payload may itself contain colons (mirrors the
connector's ``verifyToken``). Rejects an expired token and any signature
that doesn't match a secret in the verify list.
"""
try:
# base64url decode with padding restored.
padded = token + "=" * (-len(token) % 4)
decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
except (ValueError, TypeError):
return None
parts = decoded.split(":")
if len(parts) < 3:
return None
sig = parts[-1]
try:
exp = int(parts[-2])
except ValueError:
return None
payload = ":".join(parts[:-2])
if exp != 0 and int(time.time()) > exp:
return None
signed = f"{payload}:{exp}"
return payload if verify_signature(signed, sig, secrets) else None
def _delivery_payload(ts: int, body_json: str) -> str:
"""Signed material for an inbound delivery: ``f"{ts}.{body_json}"``."""
return f"{ts}.{body_json}"
def verify_delivery_signature(
body_json: str,
timestamp: Optional[str],
signature: Optional[str],
verify_keys: Sequence[str],
max_skew_seconds: int = _DEFAULT_MAX_SKEW_SECONDS,
*,
now: Optional[int] = None,
) -> bool:
"""Verify a connector→gateway inbound delivery signature.
``body_json`` MUST be the exact request body bytes decoded as UTF-8 the
connector signs over the literal serialized body, so the gateway verifies
over the literal received body (no re-serialization). Checks the timestamp
is within ``max_skew_seconds`` of now and the HMAC matches any key in the
rotation verify list. Mirrors the connector's ``verifyDeliverySignature``.
"""
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
current = now if now is not None else int(time.time())
if abs(current - ts) > max_skew_seconds:
return False
return verify_signature(_delivery_payload(ts, body_json), signature, verify_keys)

118
gateway/relay/descriptor.py Normal file
View file

@ -0,0 +1,118 @@
"""CapabilityDescriptor — the relay handshake payload. EXPERIMENTAL.
The connector hands a ``CapabilityDescriptor`` to the gateway's ``RelayAdapter``
at handshake time; it tells the adapter which platform it is fronting and which
capabilities to advertise to the ``GatewayStreamConsumer`` (char limit,
draft-streaming, edit/threading support, markdown dialect, length unit). It is
the linchpin of the generalization: one gateway adapter serves Discord,
Telegram, Matrix, Signal, ... without per-platform branching.
EXPERIMENTAL: this schema MAY CHANGE without a deprecation cycle until at least
two real Class-1 platforms have validated it. Evolution during the experimental
phase is additive-only, gated by ``contract_version`` (see
docs/relay-connector-contract.md).
Field origins (most are a wire-serializable projection of ``PlatformEntry`` plus
the per-instance capability methods on ``BasePlatformAdapter``):
- ``max_message_length`` -> ``PlatformEntry.max_message_length`` / adapter
``MAX_MESSAGE_LENGTH`` attribute (read by stream_consumer).
- ``len_unit`` -> selects which ``message_len_fn`` the adapter installs
("chars" = builtin len; "utf16" = Telegram-style UTF-16 code-unit counting).
- ``supports_draft_streaming`` -> adapter ``supports_draft_streaming()`` probe.
- ``supports_edit`` -> whether edit-based streaming is possible (Discord/
Telegram yes; Signal/SMS no -> consumer degrades to one-message-per-segment).
- ``supports_threads`` -> ``create_handoff_thread`` capability flag.
- ``markdown_dialect`` -> presentation hint (e.g. "markdown_v2", "discord").
- ``emoji`` / ``platform_hint`` / ``pii_safe`` -> ``PlatformEntry`` fields of the
same name.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
# Bump additively (never reinterpret an existing field) during the experimental
# phase; a breaking change requires updating both repos in lockstep.
CONTRACT_VERSION = 1
@dataclass(frozen=True)
class CapabilityDescriptor:
"""Immutable capability descriptor negotiated at relay handshake.
Frozen so a descriptor cannot be mutated after handshake the adapter
advertises a fixed capability profile for the life of the connection.
"""
contract_version: int
platform: str
label: str
max_message_length: int
supports_draft_streaming: bool
supports_edit: bool
supports_threads: bool
markdown_dialect: str
len_unit: str # "chars" | "utf16"
emoji: str = "\U0001f50c" # 🔌 default (matches PlatformEntry default)
platform_hint: str = ""
pii_safe: bool = False
def to_json(self) -> str:
"""Serialize to a compact, stable JSON string for the handshake frame."""
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
@classmethod
def from_json(cls, data: str) -> "CapabilityDescriptor":
"""Deserialize from a handshake JSON string.
Unknown keys are ignored (forward-compat: a newer connector may send
fields this gateway does not know yet); missing optional keys fall back
to dataclass defaults.
"""
raw = json.loads(data)
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
filtered = {k: v for k, v in raw.items() if k in known}
return cls(**filtered)
@classmethod
def from_platform_entry(
cls,
entry,
*,
len_unit: str = "chars",
supports_draft_streaming: bool = False,
supports_edit: bool = True,
supports_threads: bool = False,
markdown_dialect: str = "plain",
) -> "CapabilityDescriptor":
"""Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor.
Demonstrates the descriptor is a *subset/projection* of what
``PlatformEntry`` already encodes, not a parallel concept: ``label``,
``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and
the platform name come straight off the entry. The runtime capability
bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/
thread/markdown behavior) are supplied by the caller in production
the connector fills these from the live adapter's capability methods.
``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit";
we map that to the stream_consumer default of 4096 so the descriptor
always carries a concrete chunking bound.
"""
max_len = getattr(entry, "max_message_length", 0) or 4096
return cls(
contract_version=CONTRACT_VERSION,
platform=entry.name,
label=entry.label,
max_message_length=max_len,
supports_draft_streaming=supports_draft_streaming,
supports_edit=supports_edit,
supports_threads=supports_threads,
markdown_dialect=markdown_dialect,
len_unit=len_unit,
emoji=getattr(entry, "emoji", "\U0001f50c"),
platform_hint=getattr(entry, "platform_hint", ""),
pii_safe=getattr(entry, "pii_safe", False),
)

View file

@ -0,0 +1,204 @@
"""Gateway-side inbound delivery receiver. EXPERIMENTAL.
The connector delivers normalized inbound events to a tenant's gateway over a
**signed HTTP POST** (connector ``src/relay/httpGatewayDelivery.ts``), NOT over
the gateway's outbound ``/relay`` WebSocket: the connector instance that owns a
platform socket is generally not the instance a given gateway dialed out to, so
inbound is delivered to a tenant ENDPOINT (which may load-balance across gateway
instances). Each delivery is HMAC-signed with the per-tenant **delivery key**
(``gateway/relay/auth.py``); this receiver verifies the signature over the EXACT
raw request bytes before accepting the event.
Two routes (mirroring the connector's two POST targets):
POST {base} {"type":"message", "event": <MessageEvent>, ...}
POST {base}/interrupt {"type":"interrupt","session_key": ..., "reason"?}
The receiver:
1. reads the RAW body bytes (never a reparsed/re-serialized form the HMAC is
over the literal bytes the connector signed),
2. verifies ``x-relay-signature`` / ``x-relay-timestamp`` against the delivery
key verify list (primary + secondary during rotation), within the replay
window rejects 401 on any failure,
3. parses the JSON and dispatches: a ``message`` to the inbound handler (the
RelayAdapter's ``handle_message`` via the transport's normal path), an
``interrupt`` to the interrupt handler.
EXPERIMENTAL: the transport protocol may change without a deprecation cycle
until 2 Class-1 platforms validate it. See docs/relay-connector-contract.md.
"""
from __future__ import annotations
import json
import logging
from typing import Any, Awaitable, Callable, Optional, Sequence
from gateway.platforms.base import MessageEvent
from gateway.relay.auth import (
DELIVERY_SIG_HEADER,
DELIVERY_TS_HEADER,
verify_delivery_signature,
)
logger = logging.getLogger(__name__)
# Callbacks the receiver dispatches verified deliveries to.
InboundMessageHandler = Callable[[MessageEvent], Awaitable[None]]
InboundInterruptHandler = Callable[[str, str], Awaitable[None]]
try: # lazy/optional dep — mirrors the other HTTP-receiving adapters
from aiohttp import web
except ImportError: # pragma: no cover - exercised only when the extra is absent
web = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = web is not None
def _event_from_wire(raw: dict) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
Identical mapping to the WS transport's ``_event_from_wire`` (the wire shape
is the same; only the transport differs). Kept here so the HTTP receiver has
no import dependency on the WS transport module.
"""
from gateway.config import Platform
from gateway.platforms.base import MessageType
from gateway.session import SessionSource
src = raw.get("source", {}) or {}
platform = src.get("platform", "relay")
try:
platform_enum = Platform(platform)
except ValueError:
platform_enum = Platform.RELAY
source = SessionSource(
platform=platform_enum,
chat_id=src.get("chat_id", ""),
chat_type=src.get("chat_type", "dm"),
chat_name=src.get("chat_name"),
user_id=src.get("user_id"),
user_name=src.get("user_name"),
thread_id=src.get("thread_id"),
chat_topic=src.get("chat_topic"),
user_id_alt=src.get("user_id_alt"),
chat_id_alt=src.get("chat_id_alt"),
guild_id=src.get("guild_id"),
parent_chat_id=src.get("parent_chat_id"),
message_id=src.get("message_id"),
)
try:
msg_type = MessageType(raw.get("message_type", "text"))
except ValueError:
msg_type = MessageType.TEXT
return MessageEvent(
text=raw.get("text", ""),
message_type=msg_type,
source=source,
message_id=raw.get("message_id"),
reply_to_message_id=raw.get("reply_to_message_id"),
media_urls=raw.get("media_urls") or [],
)
class InboundDeliveryReceiver:
"""Verifies + dispatches signed connector→gateway inbound deliveries.
Transport-agnostic core: ``handle_raw`` takes the raw body bytes + headers +
which route was hit and returns ``(status, body)``. The aiohttp wiring
(``build_app`` / ``serve``) is a thin shell so the verify+dispatch logic is
unit-testable without a live socket.
"""
def __init__(
self,
*,
delivery_key_verify_list: Callable[[], Sequence[str]],
on_message: InboundMessageHandler,
on_interrupt: Optional[InboundInterruptHandler] = None,
max_skew_seconds: int = 300,
) -> None:
# A callable (not a static list) so a rotated delivery key is picked up
# without rebuilding the receiver — mirrors the connector's verify list.
self._verify_list = delivery_key_verify_list
self._on_message = on_message
self._on_interrupt = on_interrupt
self._max_skew_seconds = max_skew_seconds
async def handle_raw(
self, *, raw_body: bytes, timestamp: Optional[str], signature: Optional[str], is_interrupt: bool
) -> tuple[int, dict]:
"""Verify the signature over ``raw_body`` and dispatch. Returns (status, json).
401 on a missing/invalid/expired signature (never dispatches unverified).
400 on malformed JSON. 200 on a verified, dispatched delivery.
"""
verify_keys = list(self._verify_list() or [])
if not verify_keys:
# No delivery key provisioned -> we cannot verify -> reject. A gateway
# that hasn't enrolled must not accept inbound (fail closed).
logger.warning("relay inbound: no delivery key configured; rejecting")
return 401, {"error": "no delivery key configured"}
# Verify over the EXACT raw bytes the connector signed. Decode to text
# with the same UTF-8 the connector's JSON.stringify produced; a single
# differing byte breaks the HMAC (raw-body-preservation discipline).
body_text = raw_body.decode("utf-8", errors="strict")
if not verify_delivery_signature(
body_text, timestamp, signature, verify_keys, self._max_skew_seconds
):
return 401, {"error": "invalid delivery signature"}
try:
payload = json.loads(body_text)
except json.JSONDecodeError:
return 400, {"error": "invalid JSON body"}
if is_interrupt or payload.get("type") == "interrupt":
session_key = str(payload.get("session_key", ""))
chat_id = str(payload.get("chat_id", "") or payload.get("reason", "") or "")
if self._on_interrupt is not None and session_key:
await self._on_interrupt(session_key, chat_id)
return 200, {"ok": True}
# Default: a normalized inbound message event.
event_raw = payload.get("event")
if not isinstance(event_raw, dict):
return 400, {"error": "missing event"}
event = _event_from_wire(event_raw)
await self._on_message(event)
return 200, {"ok": True}
# ── aiohttp wiring (thin shell over handle_raw) ──────────────────────
def build_app(self) -> Any:
"""Build an aiohttp Application exposing the delivery + interrupt routes."""
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"InboundDeliveryReceiver requires the 'aiohttp' package "
"(install the messaging extra)."
)
async def _deliver(request: Any) -> Any:
return await self._respond(request, is_interrupt=False)
async def _interrupt(request: Any) -> Any:
return await self._respond(request, is_interrupt=True)
app = web.Application()
app.router.add_get("/healthz", lambda _: web.Response(text="ok"))
app.router.add_post("/", _deliver)
app.router.add_post("/interrupt", _interrupt)
return app
async def _respond(self, request: Any, *, is_interrupt: bool) -> Any:
# Read the RAW bytes — do NOT use request.json() (it reparses and we'd
# verify over a re-serialized form, breaking the HMAC).
raw_body = await request.read()
status, body = await self.handle_raw(
raw_body=raw_body,
timestamp=request.headers.get(DELIVERY_TS_HEADER),
signature=request.headers.get(DELIVERY_SIG_HEADER),
is_interrupt=is_interrupt,
)
return web.json_response(body, status=status)

101
gateway/relay/transport.py Normal file
View file

@ -0,0 +1,101 @@
"""Relay transport protocol — the gateway<->connector wire contract. EXPERIMENTAL.
The ``RelayAdapter`` (gateway side) delegates all wire I/O to a ``RelayTransport``.
The gateway dials OUT to the connector, so a production transport is a WebSocket
client; in tests it is an in-memory stub (``tests/gateway/relay/stub_connector.py``).
This module defines the protocol surface only no concrete transport. The
contract has four concerns:
1. Lifecycle: ``connect`` / ``disconnect``.
2. Handshake: ``handshake`` returns the ``CapabilityDescriptor`` the connector
advertises for the platform this adapter fronts.
3. Inbound: ``set_inbound_handler`` registers a callback the transport invokes
with each normalized ``MessageEvent`` the connector delivers.
4. Outbound: ``send_outbound`` carries send/edit/typing actions back to the
connector; ``get_chat_info`` proxies a chat-info lookup; ``send_interrupt``
routes a mid-turn /stop down the socket that owns the session_key.
EXPERIMENTAL: may change without a deprecation cycle until >=2 Class-1 platforms
validate it. See docs/relay-connector-contract.md.
"""
from __future__ import annotations
from typing import Any, Awaitable, Callable, Dict, Optional, Protocol, runtime_checkable
from gateway.platforms.base import MessageEvent
from gateway.relay.descriptor import CapabilityDescriptor
# Callback the transport invokes for each inbound normalized event.
InboundHandler = Callable[[MessageEvent], Awaitable[None]]
@runtime_checkable
class RelayTransport(Protocol):
"""Full gateway<->connector transport contract."""
async def connect(self) -> bool:
"""Open the connection to the connector; return True on success."""
...
async def disconnect(self) -> None:
"""Close the connection."""
...
async def handshake(self) -> CapabilityDescriptor:
"""Return the capability descriptor the connector advertises."""
...
def set_inbound_handler(self, handler: InboundHandler) -> None:
"""Register the callback invoked with each inbound MessageEvent."""
...
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
"""Carry an outbound action (send/edit/typing) to the connector.
Returns a result dict; for ``op == "send"`` it carries
``success`` and optionally ``message_id`` / ``error``.
"""
...
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Proxy a chat-info lookup to the connector."""
...
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
"""Route a mid-turn /stop to the connector for ``session_key``.
The connector forwards it down the socket owned by the gateway
instance running that session (the /stop routing invariant). On the
gateway side this is the OUTBOUND direction; the actual task
cancellation happens when the connector echoes an interrupt inbound
(handled in Task 1.4).
"""
...
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
"""Act on a shared-identity capability bound to a session (A2 outbound).
Some platforms hand the connector a credential that acts on the SHARED
bot identity (e.g. a Discord interaction follow-up token, valid ~15min).
Under A2 that credential NEVER reaches the gateway the connector
stripped it at the edge and bound it in its capability vault keyed by
the session. To use it, the gateway issues a SEMANTIC action against the
session it is already in; it never names or holds a token.
The action dict carries:
``op`` == ``"follow_up"``
``session_key`` the session whose bound capability to wield
``kind`` the capability kind (e.g. ``"discord.interaction_token"``)
``content`` the message content to send via that capability
``metadata?`` optional extras
The connector resolves the real capability (``resolveOutboundCapability``
on its side), enforces the tenant match (tenant B can never wield tenant
A's capability), and egresses. Returns ``{success, message_id?, error?}``;
``success`` is False when the capability is absent/expired or the tenant
doesn't match — the gateway then has nothing to retry with (by design: a
leaked gateway holds zero capability material).
"""
...

View file

@ -0,0 +1,298 @@
"""Production WebSocket RelayTransport — the gateway's live link to the connector.
The gateway dials OUT to the connector's relay endpoint over a WebSocket and
speaks the newline-delimited JSON frame protocol defined in the connector repo
(``gateway-gateway`` ``src/relay/protocol.ts``) and mirrored in
``docs/relay-connector-contract.md``:
gateway -> connector : hello, outbound, interrupt
connector -> gateway : descriptor, inbound, outbound_result, interrupt_inbound
Frames:
hello {type, platform, botId}
descriptor {type, descriptor} (handshake reply)
inbound {type, event, bufferId?} (a normalized MessageEvent)
outbound {type, requestId, action} (send/edit/typing/follow_up)
outbound_result {type, requestId, result}
interrupt {type, session_key, reason?} (gateway egresses /stop)
interrupt_inbound{type, session_key, chat_id} (connector -> owning gateway)
This is the concrete transport behind the ``RelayTransport`` Protocol; the
``RelayAdapter`` delegates all wire I/O to it. Outbound calls block on a
per-request future keyed by ``requestId`` until the matching ``outbound_result``
arrives. A background reader task pumps inbound frames to the registered handler
and resolves pending outbound futures.
EXPERIMENTAL: the frame schema may change without a deprecation cycle until at
least two Class-1 platforms validate it.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from typing import Any, Dict, Optional
from gateway.platforms.base import MessageEvent, MessageType
from gateway.session import SessionSource
from gateway.relay.descriptor import CapabilityDescriptor
from gateway.relay.transport import InboundHandler
logger = logging.getLogger(__name__)
try: # lazy/optional dep — mirrors gateway/platforms/feishu.py
import websockets
except ImportError: # pragma: no cover - exercised only when the extra is absent
websockets = None # type: ignore[assignment]
WEBSOCKETS_AVAILABLE = websockets is not None
# How long to wait for the handshake descriptor and for each outbound result.
_HANDSHAKE_TIMEOUT_S = 30.0
_OUTBOUND_TIMEOUT_S = 30.0
def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
The connector emits SessionSource as the snake_case wire form (§3); map it
back onto the gateway dataclasses. Unknown message types fall back to TEXT.
"""
src = raw.get("source", {}) or {}
from gateway.config import Platform
platform = src.get("platform", "relay")
try:
platform_enum = Platform(platform)
except ValueError:
platform_enum = Platform.RELAY
source = SessionSource(
platform=platform_enum,
chat_id=src.get("chat_id", ""),
chat_type=src.get("chat_type", "dm"),
chat_name=src.get("chat_name"),
user_id=src.get("user_id"),
user_name=src.get("user_name"),
thread_id=src.get("thread_id"),
chat_topic=src.get("chat_topic"),
user_id_alt=src.get("user_id_alt"),
chat_id_alt=src.get("chat_id_alt"),
guild_id=src.get("guild_id"),
parent_chat_id=src.get("parent_chat_id"),
message_id=src.get("message_id"),
)
try:
msg_type = MessageType(raw.get("message_type", "text"))
except ValueError:
msg_type = MessageType.TEXT
return MessageEvent(
text=raw.get("text", ""),
message_type=msg_type,
source=source,
message_id=raw.get("message_id"),
reply_to_message_id=raw.get("reply_to_message_id"),
media_urls=raw.get("media_urls") or [],
)
class WebSocketRelayTransport:
"""RelayTransport over a WebSocket connection the gateway dials to the connector."""
def __init__(
self,
url: str,
platform: str,
bot_id: str,
*,
connect_timeout_s: float = _HANDSHAKE_TIMEOUT_S,
outbound_timeout_s: float = _OUTBOUND_TIMEOUT_S,
gateway_id: Optional[str] = None,
upgrade_secret: Optional[str] = None,
) -> None:
if not WEBSOCKETS_AVAILABLE:
raise RuntimeError(
"WebSocketRelayTransport requires the 'websockets' package "
"(install the messaging extra)."
)
self._url = url
self._platform = platform
self._bot_id = bot_id
self._connect_timeout_s = connect_timeout_s
self._outbound_timeout_s = outbound_timeout_s
# Connection auth (Phase 2): when a per-gateway secret is configured the
# gateway presents an HMAC bearer on the WS upgrade so the connector can
# authenticate it (reject 4401 otherwise). gateway_id identifies the
# enrolled instance — the connector peeks it to index its secret verify
# list, then verifies the signature. Absent -> unauthenticated upgrade
# (dev/test, or a connector that doesn't enforce auth).
self._gateway_id = gateway_id
self._upgrade_secret = upgrade_secret
self._ws: Any = None
self._reader: Optional[asyncio.Task[None]] = None
self._inbound: Optional[InboundHandler] = None
self._descriptor: Optional[CapabilityDescriptor] = None
self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None
# requestId -> future awaiting the matching outbound_result.
self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
self._closing = False
# ── lifecycle ────────────────────────────────────────────────────────
async def connect(self) -> bool:
loop = asyncio.get_running_loop()
self._descriptor_ready = loop.create_future()
headers = self._upgrade_headers()
if headers:
self._ws = await websockets.connect(self._url, additional_headers=headers) # type: ignore[union-attr]
else:
self._ws = await websockets.connect(self._url) # type: ignore[union-attr]
self._reader = asyncio.create_task(self._read_loop(), name="relay-ws-reader")
# Send hello; the descriptor arrives via the reader and resolves handshake().
await self._send({"type": "hello", "platform": self._platform, "botId": self._bot_id})
return True
def _upgrade_headers(self) -> Dict[str, str]:
"""Auth headers for the WS upgrade, or {} when no secret is configured.
Presents ``Authorization: Bearer *** where the token is a signed
bearer built with the per-gateway secret (``gateway/relay/auth.py``
``make_upgrade_token``), keyed by ``gateway_id`` so the connector can
index its verify list. The connector rejects the upgrade (close 4401)
when this is missing/invalid/revoked; an unauthenticated connector
ignores it.
"""
if not (self._upgrade_secret and self._gateway_id):
return {}
from gateway.relay.auth import make_upgrade_token
token = make_upgrade_token(self._gateway_id, self._upgrade_secret)
return {"Authorization": f"Bearer {token}"}
async def disconnect(self) -> None:
self._closing = True
if self._reader is not None:
self._reader.cancel()
try:
await self._reader
except (asyncio.CancelledError, Exception): # noqa: BLE001 - best-effort teardown
pass
self._reader = None
if self._ws is not None:
try:
await self._ws.close()
except Exception: # noqa: BLE001
pass
self._ws = None
# Fail any in-flight outbound waiters so callers don't hang.
for fut in self._pending.values():
if not fut.done():
fut.set_exception(RuntimeError("relay transport closed"))
self._pending.clear()
async def handshake(self) -> CapabilityDescriptor:
if self._descriptor is not None:
return self._descriptor
if self._descriptor_ready is None:
raise RuntimeError("handshake() called before connect()")
return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s)
def set_inbound_handler(self, handler: InboundHandler) -> None:
self._inbound = handler
# ── outbound ─────────────────────────────────────────────────────────
async def send_outbound(self, action: Dict[str, Any]) -> Dict[str, Any]:
return await self._request_response(action)
async def send_follow_up(self, action: Dict[str, Any]) -> Dict[str, Any]:
# follow_up rides the same outbound frame; the connector dispatches by
# action.op. Kept as a distinct method to satisfy the transport Protocol
# and to make the A2 call site explicit.
return await self._request_response(action)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
result = await self._request_response(
{"op": "get_chat_info", "chat_id": chat_id}, frame_type="outbound"
)
# The connector answers chat-info inside the outbound_result envelope.
info = result.get("chat_info") or result
return {"name": info.get("name", chat_id), "type": info.get("type", "dm")}
async def send_interrupt(self, session_key: str, reason: Optional[str] = None) -> None:
await self._send({"type": "interrupt", "session_key": session_key, "reason": reason})
async def _request_response(
self, action: Dict[str, Any], frame_type: str = "outbound"
) -> Dict[str, Any]:
if self._ws is None:
return {"success": False, "error": "relay transport not connected"}
request_id = uuid.uuid4().hex
loop = asyncio.get_running_loop()
fut: asyncio.Future[Dict[str, Any]] = loop.create_future()
self._pending[request_id] = fut
try:
await self._send({"type": frame_type, "requestId": request_id, "action": action})
return await asyncio.wait_for(fut, timeout=self._outbound_timeout_s)
except asyncio.TimeoutError:
return {"success": False, "error": "relay outbound timed out"}
finally:
self._pending.pop(request_id, None)
# ── wire I/O ─────────────────────────────────────────────────────────
async def _send(self, frame: Dict[str, Any]) -> None:
if self._ws is None:
raise RuntimeError("relay transport not connected")
await self._ws.send(json.dumps(frame) + "\n")
async def _read_loop(self) -> None:
assert self._ws is not None
buf = ""
try:
async for chunk in self._ws:
buf += chunk if isinstance(chunk, str) else chunk.decode("utf-8")
# Newline-delimited frames; keep any trailing partial line.
*lines, buf = buf.split("\n")
for line in lines:
if line.strip():
await self._handle_frame(line)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - log + let the task end; reconnection is caller policy
if not self._closing:
logger.warning("relay ws read loop ended: %s", exc)
async def _handle_frame(self, line: str) -> None:
try:
frame = json.loads(line)
except json.JSONDecodeError:
logger.warning("relay: skipping malformed frame")
return
ftype = frame.get("type")
if ftype == "descriptor":
descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {})))
self._descriptor = descriptor
if self._descriptor_ready is not None and not self._descriptor_ready.done():
self._descriptor_ready.set_result(descriptor)
elif ftype == "inbound":
if self._inbound is not None:
event = _event_from_wire(frame.get("event", {}))
await self._inbound(event)
elif ftype == "outbound_result":
fut = self._pending.get(frame.get("requestId", ""))
if fut is not None and not fut.done():
fut.set_result(frame.get("result", {}))
elif ftype == "interrupt_inbound":
# Bridged into the adapter's interrupt path by the runner wiring.
handler = getattr(self, "_interrupt_inbound_handler", None)
if handler is not None:
await handler(frame.get("session_key", ""), frame.get("chat_id", ""))
else:
# hello/outbound/interrupt are gateway->connector; ignore if echoed.
pass
def set_interrupt_inbound_handler(self, handler: Any) -> None:
"""Register the callback for connector->gateway interrupt_inbound frames."""
self._interrupt_inbound_handler = handler

View file

@ -0,0 +1,80 @@
"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1).
Telegram does NOT echo a rich message's content back in ``reply_to_message``
when a user replies to it (verified: ``.text``/``.caption`` empty,
``.api_kwargs`` None). So replies to the launchd briefings / any rich send
arrive with no quotable text and the agent is blind to what was referenced.
Fix: remember ``message_id -> text`` at send time, look it up by
``reply_to_id`` on inbound. This module is the single source of truth for that
index.
Best-effort and dependency-free: every operation swallows errors and degrades
to a no-op / ``None`` so it can never break a send or an inbound message.
"""
from __future__ import annotations
import json
import os
import time
from typing import Optional
_MAX_ENTRIES = 1000
_MAX_TEXT_CHARS = 2000
def _store_path() -> str:
home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes")
return os.path.join(home, "state", "rich_sent_index.json")
def _key(chat_id, message_id) -> str:
return f"{chat_id}:{message_id}"
def record(chat_id, message_id, text: Optional[str]) -> None:
"""Persist ``text`` for ``(chat_id, message_id)``. No-op on any failure."""
if not text or message_id is None or chat_id is None:
return
path = _store_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
data = {}
except (FileNotFoundError, ValueError):
data = {}
data[_key(chat_id, message_id)] = {
"t": text[:_MAX_TEXT_CHARS],
"ts": int(time.time()),
}
# Trim oldest by timestamp when over cap.
if len(data) > _MAX_ENTRIES:
for k, _ in sorted(
data.items(), key=lambda kv: kv[1].get("ts", 0)
)[: len(data) - _MAX_ENTRIES]:
data.pop(k, None)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False)
os.replace(tmp, path) # atomic; tolerates concurrent writers racing
except Exception:
return
def lookup(chat_id, message_id) -> Optional[str]:
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
if message_id is None or chat_id is None:
return None
try:
with open(_store_path(), "r", encoding="utf-8") as fh:
data = json.load(fh)
entry = data.get(_key(chat_id, message_id))
if isinstance(entry, dict):
return entry.get("t") or None
except (FileNotFoundError, ValueError, AttributeError):
return None
return None

View file

@ -692,10 +692,31 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
def _message_timestamps_enabled(user_config: Optional[dict]) -> bool:
"""True when gateway.message_timestamps.enabled is opted in.
Default OFF: injecting a ``[Tue 2026-04-28 13:40:53 CEST]`` prefix onto
every user message changes what the model sees for all gateway users, so
it must be explicitly enabled in config.yaml under
``gateway.message_timestamps.enabled``.
"""
if not isinstance(user_config, dict):
return False
gw = user_config.get("gateway")
if not isinstance(gw, dict):
return False
mt = gw.get("message_timestamps")
if isinstance(mt, dict):
return bool(mt.get("enabled", False))
# Allow a bare ``message_timestamps: true`` shorthand.
return bool(mt)
def _build_gateway_agent_history(
history: List[Dict[str, Any]],
*,
channel_prompt: Optional[str] = None,
inject_timestamps: bool = False,
) -> tuple[List[Dict[str, Any]], Optional[str]]:
"""Convert stored gateway transcript rows into agent replay messages.
@ -704,8 +725,18 @@ def _build_gateway_agent_history(
turns. Keeping that context out of ``conversation_history`` avoids
consecutive-user repair merging it with the live user turn and then hiding
the current message behind ``history_offset`` during persistence.
When ``inject_timestamps`` is True (gateway.message_timestamps.enabled),
each replayed user message is rendered with a single human-readable
timestamp prefix from its stored metadata.
"""
from hermes_time import get_timezone as _get_msg_tz
from gateway.message_timestamps import (
render_user_content_with_timestamp as _render_msg_ts,
)
_msg_tz = _get_msg_tz()
agent_history: List[Dict[str, Any]] = []
observed_group_context: List[str] = []
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
@ -725,6 +756,8 @@ def _build_gateway_agent_history(
continue
content = msg.get("content")
if inject_timestamps and role == "user" and isinstance(content, str):
content = _render_msg_ts(content, msg.get("timestamp"), tz=_msg_tz)
if separate_observed_context and msg.get("observed") and role == "user" and content:
observed_group_context.append(str(content).strip())
continue
@ -5077,6 +5110,31 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"plugin discovery failed at gateway startup", exc_info=True,
)
# Register the generic relay adapter when a connector relay URL is
# configured (GATEWAY_RELAY_URL / gateway.relay_url). No URL -> no-op, so
# direct/single-tenant deployments are unaffected. When configured, the
# adapter dials the connector over a WebSocket, negotiates its capability
# descriptor at handshake, and bridges inbound/outbound like any platform.
try:
from gateway.relay import (
register_relay_adapter,
relay_url,
self_provision_if_managed,
)
# Managed boot: self-provision relay creds in-process (resolve the
# agent's NAS token -> POST /relay/provision -> set GATEWAY_RELAY_* in
# os.environ) BEFORE registration reads them. No-op when not managed,
# relay unconfigured, or a secret is already pinned. Never raises.
self_provision_if_managed()
if register_relay_adapter():
logger.info("relay adapter registered (connector at %s)", relay_url())
except Exception:
logger.warning(
"relay adapter registration failed at gateway startup", exc_info=True,
)
# Register declarative shell hooks from cli-config.yaml. Gateway
# has no TTY, so consent has to come from one of the three opt-in
# channels (--accept-hooks on launch, HERMES_ACCEPT_HOOKS env var,
@ -8259,10 +8317,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_msg_start_time = time.time()
_platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform)
_msg_preview = (event.text or "")[:80].replace("\n", " ")
_reply_id = getattr(event, "reply_to_message_id", None)
_reply_txt = (getattr(event, "reply_to_text", None) or "")[:80].replace("\n", " ")
logger.info(
"inbound message: platform=%s user=%s chat=%s msg=%r",
"inbound message: platform=%s user=%s chat=%s msg=%r reply_to_id=%s reply_to_text=%r",
_platform_name, source.user_name or source.user_id or "unknown",
source.chat_id or "unknown", _msg_preview,
source.chat_id or "unknown", _msg_preview, _reply_id, _reply_txt,
)
# Get or create session
@ -8376,6 +8436,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Read privacy.redact_pii from config (re-read per message)
_redact_pii = False
persist_user_message = None
persist_user_timestamp = None
try:
_pcfg = _load_gateway_config()
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
@ -8900,6 +8962,42 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if message_text is None:
return
# Capture the platform event time as message metadata and keep the
# persisted transcript clean (strip any leading timestamp prefix).
# This runs regardless of the toggle so storage stays clean and the
# send-time is preserved. Only the in-context RENDER (prepending the
# human-readable prefix the model sees) is gated behind
# gateway.message_timestamps.enabled — default OFF.
try:
from hermes_time import get_timezone as _get_evt_tz
from gateway.message_timestamps import (
coerce_message_timestamp as _coerce_msg_ts,
render_user_content_with_timestamp as _render_msg_ts,
strip_leading_message_timestamps as _strip_msg_ts,
)
_evt_tz = _get_evt_tz()
_evt_ts = getattr(event, "timestamp", None)
if message_text and isinstance(message_text, str):
_clean_message_text, _embedded_ts = _strip_msg_ts(
message_text, tz=_evt_tz)
persist_user_message = _clean_message_text
_event_epoch = _coerce_msg_ts(_evt_ts, tz=_evt_tz)
persist_user_timestamp = (
_event_epoch if _event_epoch is not None else _embedded_ts
)
if _message_timestamps_enabled(_load_gateway_config()):
message_text = _render_msg_ts(
_clean_message_text,
persist_user_timestamp,
tz=_evt_tz,
)
else:
# Toggle off: model sees the clean message; the timestamp
# is still stored as metadata for later opt-in.
message_text = _clean_message_text
except Exception as _ts_err:
logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err)
# Bind this gateway run generation to the adapter's active-session
# event so deferred post-delivery callbacks can be released by the
# same run that registered them.
@ -8933,6 +9031,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
run_generation=run_generation,
event_message_id=self._reply_anchor_for_event(event),
channel_prompt=event.channel_prompt,
persist_user_message=persist_user_message,
persist_user_timestamp=persist_user_timestamp,
)
# Stop persistent typing indicator now that the agent is done
@ -9224,7 +9324,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Your next message will start a fresh session."
)
ts = datetime.now().isoformat()
ts = time.time() # Unix epoch float — consistent with DB storage
# If this is a fresh session (no history), write the full tool
# definitions as the first entry so the transcript is self-describing
@ -9260,7 +9360,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# message so the next message can load a transcript that
# reflects what was said. Skip the assistant error text since
# it's a gateway-generated hint, not model output. (#7100)
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
_user_entry = {
"role": "user",
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else ts
),
}
if event.message_id:
_user_entry["message_id"] = str(event.message_id)
self.session_store.append_to_transcript(
@ -9274,7 +9386,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# If no new messages found (edge case), fall back to simple user/assistant
if not new_messages:
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
_user_entry = {
"role": "user",
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else ts
),
}
if event.message_id:
_user_entry["message_id"] = str(event.message_id)
self.session_store.append_to_transcript(
@ -9399,13 +9523,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_recent_transcript = []
for _msg in reversed(_recent_transcript[-10:]):
if _msg.get("role") == "user":
_already_persisted = (_msg.get("content") == message_text)
_expected_user_content = (
persist_user_message
if persist_user_message is not None
else message_text
)
_already_persisted = (_msg.get("content") == _expected_user_content)
break
if not _already_persisted:
_user_entry = {
"role": "user",
"content": message_text,
"timestamp": datetime.now().isoformat(),
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else time.time()
),
}
if getattr(event, "message_id", None):
_user_entry["message_id"] = str(event.message_id)
@ -13600,6 +13737,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run the agent with the given message and context.
@ -14368,6 +14507,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
log_message="agent:step hook scheduling error",
)
# Bridge sync event_callback → async hooks.emit for lifecycle events
# (e.g. session:compress fires after context compression splits a session)
def _event_callback_sync(event_type: str, context: dict) -> None:
try:
asyncio.run_coroutine_threadsafe(
_hooks_ref.emit(event_type, context),
_loop_for_step,
)
except Exception as _e:
logger.debug("event_callback hook error: %s", _e)
# Bridge sync status_callback → async adapter.send for context pressure
_status_adapter = self.adapters.get(source.platform)
_status_chat_id = source.chat_id
@ -14702,15 +14852,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent.stream_delta_callback = _stream_delta_cb
agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None
agent.status_callback = _status_callback_sync
# Credits / out-of-band notices (usage bands, depletion, restored).
# Messaging has no persistent status bar, so each notice is a
# standalone push: render to a single plaintext line and deliver via
# the shared _deliver_platform_notice rail (honors private/public +
# thread metadata). Fires from the agent's sync worker thread, so we
# hop onto the gateway loop with safe_schedule_threadsafe same
# hop onto the gateway loop with safe_schedule_threadsafe - same
# pattern as _status_callback_sync. The fired-once latch lives on the
# cached agent and persists across turns, so a band crosses one
# cached agent and persists across turns, so a band crosses -> one
# push (no per-turn re-nag). Recovery ("✓ Credit access restored")
# rides the same show path (it's emitted as a success notice, not a
# clear). The clear callback is a no-op: a sent platform message
@ -14734,6 +14883,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent.notice_callback = _notice_callback_sync
agent.notice_clear_callback = None
agent.event_callback = _event_callback_sync
agent.reasoning_config = reasoning_config
agent.service_tier = self._service_tier
agent.request_overrides = turn_route.get("request_overrides") or {}
@ -14899,6 +15049,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent_history, observed_group_context = _build_gateway_agent_history(
history,
channel_prompt=channel_prompt,
inject_timestamps=_message_timestamps_enabled(_load_gateway_config()),
)
# Collect MEDIA paths already in history so we can exclude them
@ -15015,7 +15166,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Keep real user text separate from API-only recovery guidance. If
# an auto-continue note is prepended below, persist the original
# message so stale guidance never replays as user-authored text.
_persist_user_message_override: Optional[Any] = None
_persist_user_message_override: Optional[Any] = persist_user_message
_persist_user_timestamp_override: Optional[float] = persist_user_timestamp
# Prepend pending model switch note so the model knows about the switch
_pending_notes = getattr(self, '_pending_model_notes', {})
@ -15155,6 +15307,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
elif observed_group_context:
_conversation_kwargs["persist_user_message"] = message
if _persist_user_timestamp_override is not None:
_conversation_kwargs["persist_user_timestamp"] = _persist_user_timestamp_override
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
finally:
unregister_gateway_notify(_approval_session_key)

View file

@ -386,9 +386,10 @@ def build_session_context_prompt(
lines.append("")
lines.append(
"**Platform notes:** You are running inside Yuanbao. "
"You CAN send private (DM) messages via the send_message tool. "
"Use target='yuanbao:direct:<account_id>' for DM "
"and target='yuanbao:group:<group_code>' for group chat."
"To send a private (DM) message to a user in the current group, "
"use the yb_send_dm tool (look up the recipient by name or pass "
"their user_id). Your normal reply is delivered to the group you "
"are responding in."
)
# Connected platforms
@ -1322,6 +1323,7 @@ class SessionStore:
message.get("platform_message_id") or message.get("message_id")
),
observed=bool(message.get("observed")),
timestamp=message.get("timestamp"),
)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)

View file

@ -555,6 +555,41 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
return _read_json_file(_get_runtime_status_path())
def get_runtime_status_running_pid(
runtime: Optional[dict[str, Any]] = None,
) -> Optional[int]:
"""Return a live gateway PID from the runtime status record, if valid.
``get_running_pid()`` is the primary liveness source because it verifies the
runtime lock and PID file. Launch-service managers can still leave us with
a live process and a fresh ``gateway_state.json`` but no ``gateway.pid``; use
this as a conservative fallback by checking both the persisted state and the
OS process identity.
"""
payload = runtime if runtime is not None else read_runtime_status()
if not isinstance(payload, dict):
return None
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
return None
pid = _pid_from_record(payload)
if pid is None or not _pid_exists(pid):
return None
recorded_start = payload.get("start_time")
current_start = _get_process_start_time(pid)
if (
recorded_start is not None
and current_start is not None
and current_start != recorded_start
):
return None
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(payload):
return pid
return None
def remove_pid_file() -> None:
"""Remove the gateway PID file, but only if it belongs to this process.

View file

@ -7231,23 +7231,61 @@ def _codex_device_code_login() -> Dict[str, Any]:
issuer = "https://auth.openai.com"
client_id = CODEX_OAUTH_CLIENT_ID
# Step 1: Request device code
try:
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
resp = client.post(
f"{issuer}/api/accounts/deviceauth/usercode",
json={"client_id": client_id},
headers={"Content-Type": "application/json"},
# Step 1: Request device code. OpenAI's auth endpoint rate-limits this
# request (HTTP 429) when login is attempted too often from the same
# IP/account — retry with capped backoff (honoring ``Retry-After``)
# before surfacing a clear, actionable message instead of a bare status.
resp = None
max_attempts = 4
for attempt in range(1, max_attempts + 1):
try:
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
resp = client.post(
f"{issuer}/api/accounts/deviceauth/usercode",
json={"client_id": client_id},
headers={"Content-Type": "application/json"},
)
except Exception as exc:
raise AuthError(
f"Failed to request device code: {exc}",
provider="openai-codex", code="device_code_request_failed",
)
except Exception as exc:
if resp.status_code != 429:
break
if attempt < max_attempts:
retry_after = _parse_retry_after_seconds(
getattr(resp, "headers", None)
)
# Exponential backoff (2s, 4s, 8s) capped, preferring the
# server-provided Retry-After when present.
delay = retry_after if retry_after is not None else 2 ** attempt
delay = max(1, min(int(delay), 60))
print(
"OpenAI is rate-limiting login requests "
f"(429); retrying in {delay}s..."
)
_time.sleep(delay)
if resp is not None and resp.status_code == 429:
retry_after = _parse_retry_after_seconds(getattr(resp, "headers", None))
wait_hint = (
f" Try again in about {retry_after}s."
if retry_after is not None
else " Wait a minute and run the login again."
)
raise AuthError(
f"Failed to request device code: {exc}",
provider="openai-codex", code="device_code_request_failed",
"OpenAI is rate-limiting Codex login requests (HTTP 429). "
"This is a temporary throttle on OpenAI's side, not a credential "
f"problem.{wait_hint}",
provider="openai-codex", code=CODEX_RATE_LIMITED_CODE,
)
if resp.status_code != 200:
if resp is None or resp.status_code != 200:
status = resp.status_code if resp is not None else "unknown"
raise AuthError(
f"Device code request returned status {resp.status_code}.",
f"Device code request returned status {status}.",
provider="openai-codex", code="device_code_request_error",
)
@ -7335,6 +7373,22 @@ def _codex_device_code_login() -> Dict[str, Any]:
provider="openai-codex", code="token_exchange_failed",
)
if token_resp.status_code == 429:
retry_after = _parse_retry_after_seconds(
getattr(token_resp, "headers", None)
)
wait_hint = (
f" Try again in about {retry_after}s."
if retry_after is not None
else " Wait a minute and run the login again."
)
raise AuthError(
"OpenAI is rate-limiting Codex login requests (HTTP 429) during "
"token exchange. This is a temporary throttle on OpenAI's side, "
f"not a credential problem.{wait_hint}",
provider="openai-codex", code=CODEX_RATE_LIMITED_CODE,
)
if token_resp.status_code != 200:
raise AuthError(
f"Token exchange returned status {token_resp.status_code}.",

View file

@ -64,6 +64,39 @@ _EXCLUDED_NAMES = {
"cron.pid",
}
# File names that ``hermes import`` must never overwrite, matched by basename so
# they're caught for the root profile (``gateway_state.json``) and for named
# profiles alike (``profiles/<name>/gateway_state.json``).
#
# These hold *volatile gateway/process runtime state that is namespaced to the
# machine or container the backup was taken on* — PIDs in a dead process
# namespace, a runtime lock, the process registry, and the gateway's last
# recorded run/desired state. Restoring them onto a different host (or a hosted
# container) is at best meaningless and at worst actively harmful:
#
# - ``gateway_state.json`` drives the container-boot reconciler
# (``container_boot._read_desired_state``), which only auto-starts a
# gateway whose recorded state is ``running``. A backup taken from a
# machine where the gateway was stopped (or carrying a stale/foreign
# value) overwrites the container's own state and leaves the gateway
# stuck "starting"/"cooking", disconnecting it from the Nous portal
# (NS-508 / the second half of NS-501).
# - ``gateway.pid`` / ``cron.pid`` / ``gateway.lock`` / ``processes.json``
# reference PIDs and locks in the *source* machine's process namespace; a
# numerically-equal PID in the new environment is a different process.
# These mirror exactly what ``container_boot._STALE_RUNTIME_FILES`` already
# sweeps on every container boot.
#
# Older backups predate the backup-side exclusions, so we filter on import too
# rather than trusting the archive's contents.
_IMPORT_SKIP_NAMES = {
"gateway_state.json",
"gateway.pid",
"cron.pid",
"gateway.lock",
"processes.json",
}
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"}
@ -385,6 +418,7 @@ def run_import(args) -> None:
errors = []
restored = 0
skipped_runtime: list[str] = []
t0 = time.monotonic()
for member in members:
@ -397,6 +431,16 @@ def run_import(args) -> None:
if not rel:
continue
# Never overwrite volatile gateway/process runtime state. These are
# namespaced to the machine/container the backup was taken on;
# clobbering them (especially gateway_state.json) breaks the gateway
# reconciler on the target and disconnects hosted instances from the
# Nous portal. Matched by basename so both the root profile and
# named profiles (profiles/<name>/gateway_state.json) are covered.
if Path(rel).name in _IMPORT_SKIP_NAMES:
skipped_runtime.append(rel)
continue
target = hermes_root / rel
# Security: reject absolute paths and traversals
@ -433,6 +477,16 @@ def run_import(args) -> None:
if len(errors) > 10:
print(f" ... and {len(errors) - 10} more")
if skipped_runtime:
print(
f"\n Preserved {len(skipped_runtime)} runtime state "
f"file(s) (kept this machine's, not the backup's):"
)
for rel in sorted(skipped_runtime)[:10]:
print(f" {rel}")
if len(skipped_runtime) > 10:
print(f" ... and {len(skipped_runtime) - 10} more")
# Post-import: restore profile wrapper scripts
profiles_dir = hermes_root / "profiles"
restored_profiles = []

View file

@ -1280,6 +1280,10 @@ class SlashCommandCompleter(Completer):
current word doesn't look like a path. A word is path-like when
it starts with ``./``, ``../``, ``~/``, ``/``, or contains a
``/`` separator (e.g. ``src/main.py``).
Tokens containing a ``://`` scheme separator (e.g. URLs like
``https://example.com/x``) are excluded even though they contain a
``/`` they are never useful local-path completions.
"""
if not text:
return None
@ -1291,6 +1295,12 @@ class SlashCommandCompleter(Completer):
word = text[i + 1:]
if not word:
return None
# URLs contain "/" but are not local paths. Treating them as paths fires
# os.listdir on every keystroke while typing/pasting a link (e.g. an
# https:// URL becomes a listdir of "https:") — pure latency, never a
# useful completion. Skip any token with a scheme separator.
if "://" in word:
return None
# Only trigger path completion for path-like tokens
if word.startswith(("./", "../", "~/", "/")) or "/" in word:
return word

View file

@ -350,52 +350,124 @@ def get_managed_update_command() -> Optional[str]:
return None
def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
"""Resolve the directory that holds the *running code* (the install tree).
This is the parent of ``hermes_cli/`` i.e. the git checkout for source
installs, ``/opt/hermes`` inside the published image, the venv's
site-packages root for pip installs. It is a property of the running
interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
here is immune to two installs sharing one data directory.
"""
if project_root is not None:
return project_root
return Path(__file__).parent.parent.resolve()
def detect_install_method(project_root: Optional[Path] = None) -> str:
"""Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
Resolution order:
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
3. .git directory presence -> 'git'
4. Fallback -> 'pip'
1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
running code) the authoritative marker.
2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` read for
backward compatibility, but a ``docker`` value is IGNORED when we are
not actually running inside a container (see below).
3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
4. .git directory presence -> 'git'
5. Fallback -> 'pip'
Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
--------------------------------------------------------------------------
The install method describes *the binary that is running*, but
``$HERMES_HOME`` is a shared DATA directory the Docker docs deliberately
bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist
and can be shared with a host-side Desktop/CLI install. When a
containerised gateway and a host install share one ``$HERMES_HOME``, a
home-scoped stamp is a single slot describing two different installs:
the container stamps ``docker`` on every boot, the host install then reads
``docker`` and ``hermes update`` refuses to run ("doesn't apply inside the
Docker container") even though the host binary is a perfectly updatable
git/pip install. Scoping the stamp to the install tree gives each install
its own truthful marker.
Self-healing for already-poisoned homes: a legacy ``docker`` value in the
home-scoped stamp is only honoured when we are genuinely in a container.
On a host install that read a contaminating ``docker`` stamp, we fall
through to managed/.git/pip detection instead so existing shared-home
setups recover without the user touching anything.
Note: running inside a container is NOT treated as "docker" on its own.
The two supported install paths both self-identify via the
``.install_method`` stamp (caught by step 1), so neither relies on
container detection here:
The supported installs self-identify via the code-scoped stamp:
- the curl installer (scripts/install.sh, the README/website install
command) git-clones the repo and stamps ``git``;
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
at boot via ``docker/stage2-hook.sh``.
An unsupported manual install dropped into a container (no stamp) was
wrongly classified as the published image by bare container detection,
so ``hermes update`` bailed with "doesn't apply inside the Docker
container". Without that fallback such installs fall through to the
``.git``/pip checks and behave like any off-path install. See issue #34397.
command) git-clones the repo and stamps ``git`` next to the code;
- the published ``nousresearch/hermes-agent`` image bakes a ``docker``
stamp into ``/opt/hermes`` at build time.
An unsupported manual install dropped into a container (no stamp) falls
through to the ``.git``/pip checks and behaves like any off-path install.
See issue #34397.
"""
stamp = get_hermes_home() / ".install_method"
root = _install_method_project_root(project_root)
# 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
try:
method = stamp.read_text(encoding="utf-8").strip().lower()
method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
if method:
return method
except OSError:
pass
# 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
# when we are not actually containerised: that is the signature of a
# host install whose shared $HERMES_HOME was stamped by a co-located
# container, and honouring it wrongly blocks ``hermes update``.
try:
method = (
(get_hermes_home() / ".install_method")
.read_text(encoding="utf-8")
.strip()
.lower()
)
if method and not (method == "docker" and not _running_in_container()):
return method
except OSError:
pass
managed = get_managed_system()
if managed:
return managed.lower().replace(" ", "-")
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
if (project_root / ".git").is_dir():
if (root / ".git").is_dir():
return "git"
return "pip"
def stamp_install_method(method: str) -> None:
"""Write the install method to ~/.hermes/.install_method."""
stamp = get_hermes_home() / ".install_method"
def _running_in_container() -> bool:
"""Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
try:
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.write_text(method + "\n", encoding="utf-8")
from hermes_constants import is_container
return is_container()
except Exception:
return False
def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
"""Write the install method next to the running code (code-scoped stamp).
The stamp lives in the install tree (``<install tree>/.install_method``),
not in ``$HERMES_HOME``, so that two installs sharing one data directory
do not overwrite each other's marker. See ``detect_install_method`` for
the full rationale.
Best-effort: if the install tree is read-only (e.g. the immutable
``/opt/hermes`` in the published image, which instead bakes the stamp at
build time) the write silently no-ops and detection falls back to its
other signals.
"""
root = _install_method_project_root(project_root)
try:
root.mkdir(parents=True, exist_ok=True)
(root / ".install_method").write_text(method + "\n", encoding="utf-8")
except OSError:
pass
@ -1104,6 +1176,14 @@ DEFAULT_CONFIG = {
"min_interval_hours": 24,
},
# Hard cap (chars) for a single automatic context file such as SOUL.md,
# AGENTS.md, CLAUDE.md, .hermes.md, or .cursorrules before Hermes applies
# head/tail truncation. ``null`` (the default) lets the cap scale with the
# model's context window (floor 20K, ceiling 500K) so large-context models
# rarely truncate a project doc. Set a positive integer to pin a fixed cap
# and override the dynamic behavior. Separate from read_file tool limits.
"context_file_max_chars": None,
# Maximum characters returned by a single read_file call. Reads that
# exceed this are rejected with guidance to use offset+limit.
# 100K chars ≈ 2535K tokens across typical tokenisers.
@ -1890,6 +1970,14 @@ DEFAULT_CONFIG = {
# Archive a skill (move to skills/.archive/) after this many days
# without use. Archived skills are recoverable — no auto-deletion.
"archive_after_days": 90,
# Run the LLM consolidation (umbrella-building) pass. OFF by default.
# When off, a curator run does ONLY the deterministic inactivity prune
# (mark stale / archive long-unused skills) and skips the forked
# aux-model review entirely — no umbrella-building, no aux-model cost.
# Set to true to opt back into merging overlapping skills into
# class-level umbrellas. `hermes curator run --consolidate` overrides
# this for a single invocation.
"consolidate": False,
# Also prune (archive) bundled built-in skills after the inactivity
# period, not just agent-created ones. ON by default. Built-ins are
# normally restored on every `hermes update`, so pruning them only
@ -2265,6 +2353,17 @@ DEFAULT_CONFIG = {
# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
# Inject a human-readable timestamp prefix (e.g.
# "[Tue 2026-04-28 13:40:53 CEST]") onto user messages IN THE MODEL'S
# CONTEXT so the agent has temporal awareness of when each message was
# sent. Off by default — when off, the model sees clean message text.
# Persisted transcripts always stay clean (the timestamp is stored as
# message metadata regardless of this toggle), so turning it on later
# surfaces send-times for past messages too.
"message_timestamps": {
"enabled": False,
},
# When false (default), any file path the agent emits is delivered
# as a native attachment as long as it isn't under the credential /
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,
@ -2553,7 +2652,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 29,
"_config_version": 30,
}
# =============================================================================
@ -4842,6 +4941,29 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
# ── Version 29 → 30: seed curator.consolidate (default false) ──
# Consolidation (the LLM umbrella-building fork) is now an opt-in toggle,
# OFF by default. The deterministic inactivity prune still runs whenever
# the curator is enabled; only the opinionated, aux-model-cost LLM pass is
# gated. The runtime deep-merge already supplies the default, but we seed
# the key so it's visible/editable in config.yaml. Existing installs that
# WANT the old always-consolidate behavior must set it to true explicitly.
# Only add the key when a curator section exists and lacks it — never
# clobber a value the user already set.
if current_ver < 30:
config = read_raw_config()
raw_curator = config.get("curator")
if isinstance(raw_curator, dict) and "consolidate" not in raw_curator:
raw_curator["consolidate"] = False
config["curator"] = raw_curator
save_config(config)
results["config_added"].append("curator.consolidate=false")
if not quiet:
print(
" ✓ Seeded curator.consolidate: false "
"(LLM consolidation is now opt-in; pruning stays on)"
)
# ── Post-migration: disable exfiltration-shaped MCP stdio entries ──
# Users can hand-edit mcp_servers, and older installs may already contain a
# malicious entry. Preserve the stanza for auditability but mark it

View file

@ -77,6 +77,10 @@ def _cmd_status(args) -> int:
print(f" interval: every {_interval_label}")
print(f" stale after: {curator.get_stale_after_days()}d unused")
print(f" archive after: {curator.get_archive_after_days()}d unused")
print(
f" consolidate: {'on' if curator.get_consolidate() else 'off'}"
f"{'' if curator.get_consolidate() else ' (prune-only; LLM merge pass opt-in)'}"
)
rows = skill_usage.agent_created_report()
if not rows:
@ -174,10 +178,20 @@ def _cmd_run(args) -> int:
dry = bool(getattr(args, "dry_run", False))
background = bool(getattr(args, "background", False))
synchronous = bool(getattr(args, "synchronous", False)) or not background
# --consolidate forces the LLM umbrella-building pass on for this run,
# overriding the config default (off). When the flag is absent, pass None
# so run_curator_review reads curator.consolidate from config.
consolidate = True if bool(getattr(args, "consolidate", False)) else None
if dry:
print("curator: running DRY-RUN (report only, no mutations)...")
else:
print("curator: running review pass...")
if consolidate is None and not curator.get_consolidate():
print(
"curator: consolidation is off — running prune-only "
"(deterministic stale/archive). Pass --consolidate or set "
"`curator.consolidate: true` to enable the LLM merge pass."
)
def _on_summary(msg: str) -> None:
print(msg)
@ -186,6 +200,7 @@ def _cmd_run(args) -> int:
on_summary=_on_summary,
synchronous=synchronous,
dry_run=dry,
consolidate=consolidate,
)
auto = result.get("auto_transitions", {})
if auto:
@ -503,6 +518,12 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
help="Report only — no state changes, no archives, no consolidation "
"(use this to preview what curator would do)",
)
p_run.add_argument(
"--consolidate", dest="consolidate", action="store_true",
help="Force the LLM umbrella-building consolidation pass on for this "
"run, overriding the config default (off). Without this flag the "
"run is prune-only unless `curator.consolidate: true` is set.",
)
p_run.set_defaults(func=_cmd_run)
p_pause = subs.add_parser("pause", help="Pause the curator until resumed")

View file

@ -56,6 +56,30 @@ def _get_git_commit(project_root: Path) -> str:
return "(unknown)"
def _get_git_commit_date(project_root: Path) -> str:
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or ''.
Resolves live via ``git log`` on source installs. The published Docker
image excludes ``.git``, so this returns '' there the dump line simply
drops the date suffix in that case (the baked SHA still identifies the
build).
"""
try:
result = subprocess.run(
["git", "log", "-1", "--format=%cd", "--date=short", "HEAD"],
capture_output=True, text=True, timeout=5,
cwd=str(project_root),
)
if result.returncode == 0:
value = result.stdout.strip()
if value:
return value
except Exception:
pass
return ""
def _redact(value: str) -> str:
"""Redact all but first 4 and last 4 chars.
@ -231,12 +255,12 @@ def run_dump(args):
hermes_home = get_hermes_home()
try:
from hermes_cli import __version__, __release_date__
from hermes_cli import __version__
except ImportError:
__version__ = "(unknown)"
__release_date__ = ""
commit = _get_git_commit(project_root)
commit_date = _get_git_commit_date(project_root)
try:
config = load_config()
@ -283,10 +307,14 @@ def run_dump(args):
lines = []
lines.append("--- hermes dump ---")
# Identify the build by commit + the date that commit was made, resolved
# live via git. __release_date__ (the package release date) is
# intentionally NOT shown here — it reads like a wall-clock timestamp and
# confuses support triage. The commit date is the real "as-of" date.
ver_str = f"{__version__}"
if __release_date__:
ver_str += f" ({__release_date__})"
ver_str += f" [{commit}]"
if commit_date:
ver_str += f" ({commit_date})"
lines.append(f"version: {ver_str}")
lines.append(f"os: {os_info}")
lines.append(f"python: {sys.version.split()[0]}")

View file

@ -7,6 +7,7 @@ Handles: hermes gateway [run|start|stop|restart|status|install|uninstall|setup]
import asyncio
import logging
import os
import shlex
import shutil
import signal
import subprocess
@ -3467,14 +3468,60 @@ def refresh_launchd_plist_if_needed() -> bool:
plist_path.write_text(new_plist, encoding="utf-8")
label = get_launchd_label()
domain = _launchd_domain()
target = f"{domain}/{label}"
# If this refresh is running INSIDE the gateway's own launchd process tree
# (e.g. the agent triggered a self-update via its terminal tool), a direct
# `launchctl bootout` tears down the service's process group — which
# includes THIS CLI — before the follow-up `bootstrap` can run. The gateway
# then stays unloaded and KeepAlive can't revive it (#43842). Detect that
# case and hand the reload to a detached session that survives the bootout.
gateway_pid = None
try:
from gateway.status import get_running_pid
gateway_pid = get_running_pid()
except Exception:
gateway_pid = None
if (
gateway_pid is not None
and _is_pid_ancestor_of_current_process(gateway_pid)
and hasattr(os, "setsid") # POSIX-only; launchd is macOS so always true here
):
# Delegate to a new session: `start_new_session=True` detaches the
# helper from the gateway's process group, so the bootout that kills
# the gateway (and us) does not kill the helper before it bootstraps.
reload_script = (
f"sleep 2; "
f"launchctl bootout {shlex.quote(target)} 2>/dev/null; "
f"sleep 1; "
f"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null"
)
try:
subprocess.Popen(
["/bin/bash", "-c", reload_script],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
logger.warning("Deferred launchd reload could not be spawned: %s", e)
return False
print(
"↻ Updated gateway launchd service definition; reload deferred to a "
"detached helper (refresh ran inside the gateway process tree)"
)
return True
# Bootout/bootstrap so launchd picks up the new definition
subprocess.run(
["launchctl", "bootout", f"{_launchd_domain()}/{label}"],
["launchctl", "bootout", target],
check=False,
timeout=90,
)
subprocess.run(
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
["launchctl", "bootstrap", domain, str(plist_path)],
check=False,
timeout=30,
)

View file

@ -0,0 +1,250 @@
"""``hermes gateway enroll`` — enroll a self-hosted gateway with a relay connector.
The connectorgateway channel is authenticated (the gateway may be
customer-managed and internet-exposed). This command is the gateway half of the
zero-touch enrollment in the connector repo's
``docs/connector-gateway-auth-design.md``:
1. Resolve a fresh Nous Portal access token from the existing login
(``~/.hermes/auth.json``) the same path ``hermes dashboard register``
uses (``resolve_nous_access_token``). This proves *which Nous org (tenant)*
the caller owns; the connector derives the authoritative tenant from it via
``GET /api/oauth/account`` (never from anything the gateway asserts).
2. POST ``{enrollmentToken, gatewayId}`` to the connector's ``/relay/enroll``
with that token in the ``Authorization`` header, over TLS.
3. The connector verifies the enrollment token (signature + single-use +
tenant match), mints a per-gateway secret, get-or-creates the per-tenant
delivery key, and returns both ONCE.
4. Persist ``GATEWAY_RELAY_ID`` / ``GATEWAY_RELAY_SECRET`` /
``GATEWAY_RELAY_DELIVERY_KEY`` (+ ``GATEWAY_RELAY_URL`` if supplied) into
``~/.hermes/.env``. The per-gateway secret authenticates the WS upgrade;
the per-tenant delivery key verifies signed inbound deliveries.
Managed/hosted installs do NOT self-enroll: the orchestrator (NAS) mints the
secret directly and stamps it into the container env, so this command refuses to
run under ``is_managed()`` (mirrors ``dashboard register``).
EXPERIMENTAL: the relay auth scheme may change without a deprecation cycle until
2 Class-1 platforms validate the contract.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import urllib.error
import urllib.request
from typing import Optional
def _default_gateway_id() -> str:
"""A stable-ish default gateway instance id: ``<hostname>-<pid-free slug>``.
The gatewayId identifies this enrolled instance for kill-switch granularity
(the connector indexes its secret verify list by it). Default to the host
name so a human can recognize it; overridable via ``--gateway-id``.
"""
host = ""
try:
host = socket.gethostname().strip()
except Exception:
host = ""
return f"gw-{host or 'hermes'}"
def _resolve_connector_url(override: Optional[str]) -> Optional[str]:
"""Resolve the connector base URL (no trailing slash) for enrollment.
Precedence: explicit ``--connector-url`` flag > ``GATEWAY_RELAY_URL`` env >
``gateway.relay_url`` in config.yaml. The relay URL is a ``ws(s)://`` dial
target; enrollment is an ``http(s)://`` POST to the same host, so we map the
scheme. Returns None when nothing is configured (the user must supply one).
"""
raw = (override or os.environ.get("GATEWAY_RELAY_URL", "")).strip()
if not raw:
try:
from gateway.run import _load_gateway_config # late import to avoid cycle
cfg = (_load_gateway_config().get("gateway") or {})
raw = str(cfg.get("relay_url", "") or "").strip()
except Exception:
raw = ""
if not raw:
return None
raw = raw.rstrip("/")
# The relay dial URL is ws(s)://…/relay; enrollment posts to http(s)://…/relay/enroll.
if raw.startswith("ws://"):
raw = "http://" + raw[len("ws://"):]
elif raw.startswith("wss://"):
raw = "https://" + raw[len("wss://"):]
# Strip a trailing /relay path segment if the user pasted the dial URL.
if raw.endswith("/relay"):
raw = raw[: -len("/relay")]
return raw
def _post_enroll(
*,
connector_base_url: str,
access_token: str,
enrollment_token: str,
gateway_id: str,
timeout: float = 15.0,
) -> dict:
"""POST to the connector's ``/relay/enroll`` and return the JSON body.
Raises RuntimeError with a user-facing message on any non-2xx / transport
failure. The connector returns ``{secret, deliveryKey, tenant, gatewayId}``
on success, ``{error}`` at 400/401/403.
"""
url = f"{connector_base_url.rstrip('/')}/relay/enroll"
data = json.dumps({"enrollmentToken": enrollment_token, "gatewayId": gateway_id}).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = (json.loads(exc.read().decode()) or {}).get("error", "")
except Exception:
pass
if exc.code == 401:
raise RuntimeError(
"Connector rejected the caller identity (401). Your Nous Portal "
"token could not be verified — try `hermes auth login nous` and retry."
) from exc
if exc.code == 403:
raise RuntimeError(
detail
or "Enrollment token invalid, expired, already used, or tenant mismatch (403)."
) from exc
raise RuntimeError(
f"Connector returned HTTP {exc.code}" + (f": {detail}" if detail else "")
) from exc
except urllib.error.URLError as exc:
raise RuntimeError(
f"Could not reach the connector at {connector_base_url}: {exc.reason}"
) from exc
if not isinstance(payload, dict) or not payload.get("secret"):
raise RuntimeError("Connector returned an unexpected response (no secret).")
return payload
def cmd_gateway_enroll(args) -> None:
"""Enroll this gateway with a relay connector; persist the auth creds to .env."""
from hermes_cli.auth import AuthError, resolve_nous_access_token
from hermes_cli.config import is_managed, save_env_value
# Managed installs get GATEWAY_RELAY_* stamped in by the orchestrator (NAS
# mints the secret directly per the design's managed shape). Self-enrolling
# from inside such a container is a mistake — and save_env_value refuses to
# write anyway.
if is_managed():
print(
"✗ `hermes gateway enroll` is not available in a managed/hosted install.\n"
" The relay gateway secret is provisioned by the hosting platform."
)
sys.exit(1)
enrollment_token = (getattr(args, "token", None) or os.environ.get("GATEWAY_RELAY_ENROLL_TOKEN", "")).strip()
if not enrollment_token:
print(
"✗ No enrollment token. Pass --token <token> (or set "
"GATEWAY_RELAY_ENROLL_TOKEN).\n"
" The connector mints this single-use token when your tenant's route "
"is provisioned; it is delivered with your gateway config."
)
sys.exit(1)
connector_base_url = _resolve_connector_url(getattr(args, "connector_url", None))
if not connector_base_url:
print(
"✗ No connector URL. Pass --connector-url <url> (or set GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml)."
)
sys.exit(1)
gateway_id = (getattr(args, "gateway_id", None) or _default_gateway_id()).strip()
# 1. Resolve a fresh Nous access token (the tenant-proving identity).
try:
access_token = resolve_nous_access_token()
except AuthError as exc:
if getattr(exc, "relogin_required", False):
print("✗ You're not logged into Nous Portal.")
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
else:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
except Exception as exc:
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
sys.exit(1)
# 2-3. Redeem the enrollment token at the connector.
try:
result = _post_enroll(
connector_base_url=connector_base_url,
access_token=access_token,
enrollment_token=enrollment_token,
gateway_id=gateway_id,
)
except RuntimeError as exc:
print(f"✗ Enrollment failed: {exc}")
sys.exit(1)
secret = str(result.get("secret") or "")
delivery_key = str(result.get("deliveryKey") or "")
tenant = str(result.get("tenant") or "")
resolved_gateway_id = str(result.get("gatewayId") or gateway_id)
# 4. Persist the creds idempotently. The secret + delivery key are sensitive;
# save_env_value writes them to ~/.hermes/.env (0600 dir) and never logs.
to_write = {
"GATEWAY_RELAY_ID": resolved_gateway_id,
"GATEWAY_RELAY_SECRET": secret,
"GATEWAY_RELAY_DELIVERY_KEY": delivery_key,
}
# Persist the connector URL too (as the ws(s):// dial target) when supplied
# explicitly, so the runtime can dial without re-specifying it.
explicit_url = (getattr(args, "connector_url", None) or "").strip()
if explicit_url:
to_write["GATEWAY_RELAY_URL"] = explicit_url.rstrip("/")
for key, value in to_write.items():
if not value:
continue
try:
save_env_value(key, value)
except Exception as exc:
print(f"✗ Failed to write {key} to .env: {exc}")
sys.exit(1)
from hermes_cli.config import get_env_path
print(f'✓ Enrolled gateway "{resolved_gateway_id}"' + (f" for tenant {tenant}" if tenant else ""))
print()
print(f" Wrote to {get_env_path()}:")
print(f" GATEWAY_RELAY_ID={resolved_gateway_id}")
print(" GATEWAY_RELAY_SECRET=<hidden>")
print(" GATEWAY_RELAY_DELIVERY_KEY=<hidden>")
if explicit_url:
print(f" GATEWAY_RELAY_URL={explicit_url.rstrip('/')}")
print()
print(
" The gateway now authenticates its relay WS upgrade with the per-gateway\n"
" secret and verifies signed inbound deliveries with the tenant delivery\n"
" key. Restart the gateway to pick up the new env."
)

View file

@ -178,6 +178,14 @@ def build_models_payload(
user_models.update(m.lower() for m in (row.get("models") or []))
if user_models:
for row in rows:
# A user's own configured provider is never an "aggregator
# duplicate" of itself: user_models is built from these very
# rows, and is_aggregator() reports True for every custom:*
# slug. Without this guard the dedup strips a user-defined
# custom provider's entire model list (all of it lives in
# user_models), emptying its picker row.
if row.get("is_user_defined"):
continue
slug = row.get("slug", "")
if not _is_aggregator(slug):
continue

View file

@ -5110,6 +5110,113 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]:
return removed
# Last-resort Electron mirror after GitHub download fails (#47266). Only used
# when the user hasn't pinned ELECTRON_MIRROR.
_ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
def _electron_dir(project_root: Path) -> Path:
"""Return the Electron package directory the desktop workspace installs.
npm may keep workspace-only dev dependencies under
``apps/desktop/node_modules`` instead of hoisting them to the repo root.
Which layout you get depends on the npm version and what else is installed,
so a build path that assumes one or the other breaks intermittently across
machines. ``apps/desktop/package.json`` points electron-builder's
``electronDist`` at ``node_modules/electron/dist`` relative to the desktop
project, so prefer the workspace-local package and fall back to the root
hoist when that's where npm landed it.
"""
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
if desktop_local.exists():
return desktop_local
return project_root / "node_modules" / "electron"
def _electron_dist_binary(project_root: Path) -> Path:
"""Return the path to the Electron main binary inside the installed package.
electron-builder reads the binary from ``build.electronDist`` since #38673,
so this is the exact file whose absence makes a pack fail with "The
specified electronDist does not exist". The basename differs per OS (the
platform Electron is named for the host the build runs on).
"""
dist = _electron_dir(project_root) / "dist"
if sys.platform == "darwin":
return dist / "Electron.app" / "Contents" / "MacOS" / "Electron"
if sys.platform == "win32":
return dist / "electron.exe"
return dist / "electron"
def _electron_dist_ok(project_root: Path) -> bool:
"""True when ``node_modules/electron/dist`` holds a usable Electron binary.
A directory that exists but is missing the binary (a partial extraction from
a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since
that is exactly the shape that makes electron-builder throw on the pinned
electronDist.
"""
try:
return _electron_dist_binary(project_root).exists()
except OSError:
return False
def _electron_pkg_staged_missing_dist(project_root: Path) -> bool:
"""electron staged (package.json + install.js) but dist missing — blocked postinstall."""
electron_dir = _electron_dir(project_root)
return (
(electron_dir / "package.json").is_file()
and (electron_dir / "install.js").is_file()
and not _electron_dist_ok(project_root)
)
def _redownload_electron_dist(
project_root: Path,
env: dict,
*,
mirror: Optional[str] = None,
) -> bool:
"""Best-effort: run electron's install.js to populate dist/ (optional mirror)."""
if _electron_dist_ok(project_root):
return True
electron_dir = _electron_dir(project_root)
installer = electron_dir / "install.js"
if not installer.is_file():
return False
node = shutil.which("node")
if not node:
return False
dist_dir = electron_dir / "dist"
shutil.rmtree(dist_dir, ignore_errors=True)
try:
(electron_dir / "path.txt").unlink()
except OSError:
pass
dl_env = dict(env)
if mirror:
dl_env["ELECTRON_MIRROR"] = mirror
try:
subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False)
except OSError:
return False
return _electron_dist_ok(project_root)
def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
"""Canonical download, then fallback mirror unless the user pinned one."""
if _redownload_electron_dist(project_root, env):
return True
if env.get("ELECTRON_MIRROR"):
return False
return _redownload_electron_dist(project_root, env, mirror=_ELECTRON_FALLBACK_MIRROR)
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
"""Terminate any running desktop app executing from this build's ``release``
dir so a rebuild can replace its (otherwise locked) executable.
@ -5303,8 +5410,8 @@ def cmd_gui(args: argparse.Namespace):
print(" Pre-build first: cd apps/desktop && npm run build")
print(" Or drop --skip-build to install dependencies and build automatically.")
sys.exit(1)
if not (PROJECT_ROOT / "node_modules" / "electron" / "package.json").exists():
print("✗ --skip-build --source requires existing workspace dependencies.")
if not (_electron_dir(PROJECT_ROOT) / "package.json").exists():
print("✗ --skip-build --source requires existing desktop workspace dependencies.")
print(f" Install first: cd {PROJECT_ROOT} && npm ci")
print(" Or drop --skip-build to install dependencies and build automatically.")
sys.exit(1)
@ -5332,9 +5439,18 @@ def cmd_gui(args: argparse.Namespace):
nixos_env = _nixos_build_env()
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
if install_result.returncode != 0:
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
if not _electron_pkg_staged_missing_dist(PROJECT_ROOT):
print("✗ Desktop dependency install failed")
print(f" Run manually: cd {PROJECT_ROOT} && npm ci")
sys.exit(install_result.returncode or 1)
repaired = _try_redownload_electron_dist(PROJECT_ROOT, env)
if repaired:
print(" ⚠ Dependency install failed with a missing Electron dist; "
"repopulated it and continuing.")
else:
print(" ⚠ Dependency install failed with a missing Electron dist; "
"continuing to the build so electron-builder can attempt "
"the Electron fetch itself.")
build_label = "source build" if source_mode else "packaged app"
print(f"→ Building desktop {build_label}...")
@ -5350,22 +5466,16 @@ def cmd_gui(args: argparse.Namespace):
print(f" ⚠ Stopped running desktop app to free the build output (pid {', '.join(map(str, stopped))})")
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode:
# A corrupt cached Electron zip makes `pack` fail with an ENOENT
# on the final `electron` -> `Hermes` rename: unpack-electron
# extracted a partial tree (missing the 193 MB binary) from the
# bad zip. We do NOT try to prove the zip is corrupt ourselves —
# stdlib zipfile silently tolerates the prepended/concatenated
# junk that is the most common corruption (a partial download
# resumed into the same file), so a `testzip()` gate would pass
# and never self-heal. Instead, on any packaged-build failure we
# purge the version's cached zip + the half-written unpacked dir
# and retry once: @electron/get re-downloads with its own SHASUM
# verification, which is the real source of truth. If the
# failure was something else, the clean re-download is harmless
# and the retry fails the same way.
purged = _purge_electron_build_cache(desktop_dir)
if purged:
print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...")
# Corrupt cached Electron zip → partial unpack → ENOENT on rename.
# stdlib zipfile won't catch the common concat-junk case, so purge
# and retry once; @electron/get SHASUM is the real gate.
purged: list[Path] = []
restored = False
if not _electron_dist_ok(PROJECT_ROOT):
purged = _purge_electron_build_cache(desktop_dir)
restored = _redownload_electron_dist(PROJECT_ROOT, env)
if restored:
print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...")
for p in purged:
print(f" - {p}")
# The purge can't remove a win-unpacked tree whose Hermes.exe
@ -5373,20 +5483,14 @@ def cmd_gui(args: argparse.Namespace):
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
# Still failing and the user hasn't pinned a mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" download log). Retry once via npmmirror.com — the
# de-facto Electron community mirror (Alibaba). @electron/get
# SHASUM-checks the download, but the SHASUMS come from the same
# mirror, so that guards against a corrupt/partial download, NOT
# a compromised mirror: reaching for it is an explicit trust
# trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Retrying once via a public mirror "
"GitHub looks blocked. Re-downloading via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror = _ELECTRON_FALLBACK_MIRROR
mirror_env = dict(env)
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
mirror_env["ELECTRON_MIRROR"] = mirror
if not _electron_dist_ok(PROJECT_ROOT):
_redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror)
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
if build_result.returncode != 0:
@ -10903,6 +11007,13 @@ def cmd_dashboard_register(args):
_impl(args)
def cmd_gateway_enroll(args):
"""Enroll a self-hosted gateway with a relay connector."""
from hermes_cli.gateway_enroll import cmd_gateway_enroll as _impl
_impl(args)
def cmd_completion(args, parser=None):
"""Print shell completion script."""
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
@ -11595,7 +11706,9 @@ def main():
# =========================================================================
# gateway + proxy commands (parsers built in hermes_cli/subcommands/gateway.py)
# =========================================================================
build_gateway_parser(subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy)
build_gateway_parser(
subparsers, cmd_gateway=cmd_gateway, cmd_proxy=cmd_proxy, cmd_gateway_enroll=cmd_gateway_enroll
)
# =========================================================================
# lsp command

View file

@ -517,7 +517,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
pass
models = list(_PROVIDER_MODELS.get("xai-oauth") or _PROVIDER_MODELS.get("xai") or [])
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-4.3"))
selected = _prompt_model_selection(models, current_model=current_model or (models[0] if models else "grok-build-0.1"))
if selected:
_save_model_choice(selected)
_update_config_for_provider("xai-oauth", base_url)

View file

@ -1735,10 +1735,15 @@ def list_authenticated_providers(
if fb:
models_list = list(fb)
# Prefer the endpoint's live /models list when credentials are
# available, unless the provider explicitly opts out via
# discover_models: false (e.g. dedicated endpoints that expose
# the entire aggregator catalog via /models).
# Prefer the endpoint's live /models list when discoverable,
# unless the provider explicitly opts out via discover_models: false.
# Policy mirrors Section 4's should_probe logic:
# - With an api_key: always probe (user opted into the endpoint).
# - Without an api_key but with explicit models: skip — the user
# is narrowing a public endpoint to a specific subset.
# - Without an api_key AND no explicit models: probe anyway so
# bare-endpoint providers (local llama.cpp / Ollama servers)
# still show their full model catalog.
api_key = str(ep_cfg.get("api_key", "") or "").strip()
if not api_key:
key_env = str(ep_cfg.get("key_env", "") or "").strip()
@ -1746,7 +1751,11 @@ def list_authenticated_providers(
discover = ep_cfg.get("discover_models", True)
if isinstance(discover, str):
discover = discover.lower() not in {"false", "no", "0"}
if api_url and api_key and discover:
has_explicit_models = bool(models_list)
should_probe = bool(api_url) and discover and (
bool(api_key) or not has_explicit_models
)
if should_probe:
try:
from hermes_cli.models import fetch_api_models
live_models = fetch_api_models(api_key, api_url)

View file

@ -61,6 +61,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# MiniMax
("minimax/minimax-m3", ""),
# Z-AI
("z-ai/glm-5.2", ""),
("z-ai/glm-5.1", ""),
# Xiaomi
("xiaomi/mimo-v2.5-pro", ""),
@ -109,14 +110,20 @@ def _codex_curated_models() -> list[str]:
# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},
# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).
_XAI_STATIC_FALLBACK: list[str] = [
"grok-build-0.1",
"grok-4.3",
"grok-4.20-0309-reasoning",
"grok-4.20-0309-non-reasoning",
"grok-4.20-multi-agent-0309",
]
# Callable via xAI OAuth but omitted from models.dev and /v1/models listings.
_XAI_CURATED_EXTRAS: list[str] = [
"grok-composer-2.5-fast",
]
_XAI_TOP_MODEL = "grok-4.3"
_XAI_TOP_MODEL = "grok-build-0.1"
def _xai_promote_top(ids: list[str]) -> list[str]:
@ -126,6 +133,18 @@ def _xai_promote_top(ids: list[str]) -> list[str]:
return ids
def _xai_merge_curated_extras(ids: list[str]) -> list[str]:
"""Append Hermes-curated xAI models that are missing from models.dev."""
out = list(ids)
for extra in _XAI_CURATED_EXTRAS:
if extra in out:
continue
# Keep the headline model pinned; slot extras immediately after it.
insert_at = 1 if out and out[0] == _XAI_TOP_MODEL else len(out)
out.insert(insert_at, extra)
return out
def _xai_curated_models() -> list[str]:
"""Derive the xAI-direct curated list from models.dev disk cache.
@ -145,12 +164,12 @@ def _xai_curated_models() -> list[str]:
if isinstance(models, dict) and models:
ids = [mid for mid in models.keys() if isinstance(mid, str)]
if ids:
return _xai_promote_top(sorted(ids))
return _xai_merge_curated_extras(_xai_promote_top(sorted(ids)))
except Exception:
# Any failure (missing file, malformed JSON, import error)
# falls through to the static list.
pass
return list(_XAI_STATIC_FALLBACK)
return _xai_merge_curated_extras(list(_XAI_STATIC_FALLBACK))
_PROVIDER_MODELS: dict[str, list[str]] = {
@ -182,6 +201,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
# MiniMax
"minimax/minimax-m3",
# Z-AI
"z-ai/glm-5.2",
"z-ai/glm-5.1",
# Xiaomi
"xiaomi/mimo-v2.5-pro",
@ -2368,10 +2388,17 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if not base_url:
base_url = _p.base_url
if api_key:
live = _p.fetch_models(api_key=api_key)
live = _p.fetch_models(api_key=api_key, base_url=base_url or None)
if live:
if normalized in {"kimi-coding", "kimi-coding-cn"}:
curated = list(_PROVIDER_MODELS.get(normalized, []))
# Merge static curated list with live API results so
# models that the live endpoint omits (stale cache,
# partial rollout) still appear in the picker.
# Curated entries come first so deliberately-surfaced
# newest models (e.g. kimi-k2.7-code, #46309) stay at
# the top of the picker; live-only entries are appended
# afterwards for discovery. (#46850)
curated = list(_PROVIDER_MODELS.get(normalized, []))
if curated:
merged = list(curated)
merged_lower = {m.lower() for m in curated}
for m in live:
@ -3934,6 +3961,24 @@ def validate_requested_model(
if suggestions:
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
# Model not in live /v1/models — check the curated catalog
# before rejecting. Providers may omit models from their live
# listing that are still valid (stale cache, partial rollout,
# gated previews). Use the pure-catalog helper (no extra live
# fetch) so we only accept models Hermes actually ships. (#46850)
if _model_in_provider_catalog(
requested_for_lookup.lower(), _provider_keys(normalized)
):
return {
"accepted": True,
"persist": True,
"recognized": True,
"message": (
f"Note: `{requested}` was not found in the live /v1/models listing "
f"but exists in the curated catalog — accepted."
),
}
return {
"accepted": False,
"persist": False,

View file

@ -684,10 +684,25 @@ class S6ServiceManager:
# start`, etc. See `_gateway_command_inner` for the matching
# guard.
lines.append("export HERMES_S6_SUPERVISED_CHILD=1")
# ``--replace`` makes the supervised gateway authoritative for its
# profile's HERMES_HOME. Without it, a gateway started OUTSIDE s6
# (a stray ``hermes gateway run`` from a shell, an agent action, or
# the Open WebUI helper) grabs the per-HERMES_HOME PID lock first;
# the supervised slot then execs a bare ``gateway run``, hits the
# "Another gateway instance is already running" guard, exits
# non-zero, and s6 restarts it — a restart loop that floods the
# log and never binds (NS-505). ``--replace``
# instead reaps the stale holder (hardened takeover path: marker +
# SIGTERM→SIGKILL-with-confirmation + scoped-lock cleanup, see
# gateway/run.py) so s6 always wins. The HERMES_S6_SUPERVISED_CHILD
# sentinel above prevents the run→start→run redirect recursion.
# Each profile is scoped to its own HERMES_HOME and s6 guarantees a
# single supervised instance per slot, so there is no legitimate
# supervised sibling for ``--replace`` to clobber.
if profile == "default":
gateway_cmd = "hermes gateway run"
gateway_cmd = "hermes gateway run --replace"
else:
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run"
gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run --replace"
# Skip the drop when already non-root (setgroups() lacks CAP_SETGID →
# s6 boot-loop).
lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}')

View file

@ -29,7 +29,9 @@ def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
)
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
def build_gateway_parser(
subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable, cmd_gateway_enroll: Callable
) -> None:
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
# =========================================================================
# gateway command
@ -236,6 +238,52 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
help="Skip the confirmation prompt",
)
# gateway enroll — enroll a self-hosted gateway with a relay connector
# (connector⇄gateway auth). Redeems a single-use enrollment token for the
# per-gateway secret + per-tenant delivery key and writes them to .env.
# See docs/relay-connector-contract.md (and the connector repo's
# docs/connector-gateway-auth-design.md). EXPERIMENTAL.
gateway_enroll = gateway_subparsers.add_parser(
"enroll",
help="Enroll this gateway with a relay connector (writes relay auth creds to .env)",
description=(
"Redeem a single-use enrollment token with a relay connector. "
"Authenticates as your Nous Portal account (the connector derives the "
"authoritative tenant from it), mints this gateway's per-gateway secret "
"and per-tenant delivery key, and writes GATEWAY_RELAY_ID / "
"GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY into ~/.hermes/.env. "
"Requires being logged in (hermes setup). Not available in managed installs."
),
)
gateway_enroll.add_argument(
"--token",
default=None,
help=(
"The single-use enrollment token from the connector (delivered with "
"your gateway config). Also settable via GATEWAY_RELAY_ENROLL_TOKEN."
),
)
gateway_enroll.add_argument(
"--connector-url",
dest="connector_url",
default=None,
help=(
"The connector base/relay URL, e.g. wss://connector.example.com/relay "
"or https://connector.example.com. Also settable via GATEWAY_RELAY_URL "
"/ gateway.relay_url in config.yaml."
),
)
gateway_enroll.add_argument(
"--gateway-id",
dest="gateway_id",
default=None,
help=(
"A stable id for this gateway instance (kill-switch granularity). "
"Defaults to gw-<hostname>."
),
)
gateway_enroll.set_defaults(func=cmd_gateway_enroll)
# =========================================================================
# proxy command — local OpenAI-compatible proxy that attaches the user's
# OAuth-authenticated provider credentials to outbound requests. Lets

View file

@ -10,20 +10,40 @@ from typing import Callable
def build_login_parser(subparsers, *, cmd_login: Callable) -> None:
"""Attach the ``login`` subcommand to ``subparsers``."""
# =========================================================================
# login command
# =========================================================================
"""Attach the deprecated ``login`` subcommand to ``subparsers``.
``hermes login`` was removed in favor of ``hermes auth`` / ``hermes model``
(the runtime handler in ``hermes_cli/auth.py::login_command`` just prints a
deprecation message and exits). The subparser is kept registered so that
old scripts/aliases invoking ``hermes login [--flags]`` still receive the
actionable deprecation message rather than an argparse ``invalid choice:
'login'`` error but:
- The subparser is registered WITHOUT a ``help=`` kwarg so the row is
omitted from ``hermes --help`` (argparse only lists subcommands that
have a help string). This hides a command that no longer works (#24756)
without the ``help=argparse.SUPPRESS`` ``==SUPPRESS==`` leak that
argparse emits for a top-level subparser on Python 3.12+.
- ``--provider`` accepts ANY value (no ``choices=``) so that, e.g.,
``hermes login --provider anthropic`` reaches the deprecation handler and
gets pointed at ``hermes model`` instead of crashing in argparse with
``invalid choice: 'anthropic'`` before the handler can run.
"""
login_parser = subparsers.add_parser(
"login",
help="Authenticate with an inference provider",
description="Run OAuth device authorization flow for Hermes CLI",
description=(
"Deprecated. Use `hermes auth` to manage credentials, "
"`hermes model` to select a provider, or `hermes setup` for full setup."
),
)
# No ``choices=`` on purpose — the handler is a deprecation notice that
# ignores the value, and a restrictive list would reject providers the user
# legitimately wants (e.g. ``anthropic``) with an argparse error before the
# friendly redirect message is ever printed.
login_parser.add_argument(
"--provider",
choices=["nous", "openai-codex", "xai-oauth"],
default=None,
help="Provider to authenticate with (default: nous)",
help="(deprecated) Provider name; ignored — see `hermes model`",
)
login_parser.add_argument(
"--portal-url", help="Portal base URL (default: production portal)"

View file

@ -73,7 +73,6 @@ CONFIGURABLE_TOOLSETS = [
("clarify", "❓ Clarifying Questions", "clarify"),
("delegation", "👥 Task Delegation", "delegate_task"),
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("messaging", "📨 Cross-Platform Messaging", "send_message"),
("homeassistant", "🏠 Home Assistant", "smart home device control"),
("spotify", "🎵 Spotify", "playback, search, playlists, library"),
("discord", "💬 Discord (read/participate)", "fetch messages, search members, create thread"),

View file

@ -62,7 +62,11 @@ from hermes_cli.config import (
recommended_update_command_for_method,
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
from gateway.status import (
get_running_pid,
get_runtime_status_running_pid,
read_runtime_status,
)
from utils import env_var_enabled
try:
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str]
profile: Optional[str] = None
class AudioTranscriptionRequest(BaseModel):
@ -1609,145 +1614,174 @@ async def fs_default_cwd():
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
async def get_status(profile: Optional[str] = None):
status_scope = None
requested_profile = (profile or "").strip()
# Plain /api/status stays the machine-level public liveness probe. The
# dashboard adds ?profile= when its management switcher targets another
# profile, so its gateway badge reflects the selected profile.
#
# Use the config-only (contextvar) scope, NOT _profile_scope: this handler
# awaits the remote-health probe, and _profile_scope swaps process-global
# skills-module attributes that a concurrent request would cross-restore
# across that await. Status only resolves get_hermes_home() at call time
# (config/env/gateway state), which the task-local contextvar covers.
if requested_profile and requested_profile.lower() != "current":
status_scope = _config_profile_scope(requested_profile)
status_scope.__enter__()
# --- Gateway liveness detection ---
# Try local PID check first (same-host). If that fails and a remote
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
# dashboard works when the gateway runs in a separate container.
gateway_pid = get_running_pid()
gateway_running = gateway_pid is not None
remote_health_body: dict | None = None
if not gateway_running and _GATEWAY_HEALTH_URL:
loop = asyncio.get_running_loop()
alive, remote_health_body = await loop.run_in_executor(
None, _probe_gateway_health
)
if alive:
gateway_running = True
# PID from the remote container (display only — not locally valid)
if remote_health_body:
gateway_pid = remote_health_body.get("pid")
gateway_state = None
gateway_platforms: dict = {}
gateway_exit_reason = None
gateway_updated_at = None
configured_gateway_platforms: set[str] | None = None
try:
from gateway.config import load_gateway_config
current_ver, latest_ver = check_config_version()
# --- Gateway liveness detection ---
# Try local PID check first (same-host). If that fails and a remote
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
# dashboard works when the gateway runs in a separate container.
gateway_pid = get_running_pid()
gateway_running = gateway_pid is not None
remote_health_body: dict | None = None
gateway_config = load_gateway_config()
configured_gateway_platforms = {
platform.value for platform in gateway_config.get_connected_platforms()
}
except Exception:
configured_gateway_platforms = None
# Prefer the detailed health endpoint response (has full state) when the
# local runtime status file is absent or stale (cross-container).
runtime = read_runtime_status()
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
runtime = remote_health_body
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
if configured_gateway_platforms is not None:
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if key in configured_gateway_platforms
}
gateway_exit_reason = runtime.get("exit_reason")
gateway_updated_at = runtime.get("updated_at")
if not gateway_running:
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
gateway_platforms = {}
elif gateway_running and remote_health_body is not None:
# The health probe confirmed the gateway is alive, but the local
# runtime status file may be stale (cross-container). Override
# stopped/None state so the dashboard shows the correct badge.
if gateway_state in {None, "stopped"}:
gateway_state = "running"
# If there was no runtime info at all but the health probe confirmed alive,
# ensure we still report the gateway as running (no shared volume scenario).
if gateway_running and gateway_state is None and remote_health_body is not None:
gateway_state = "running"
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
try:
sessions = db.list_sessions_rich(limit=50)
now = time.time()
active_sessions = sum(
1 for s in sessions
if s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
if not gateway_running and _GATEWAY_HEALTH_URL:
loop = asyncio.get_running_loop()
alive, remote_health_body = await loop.run_in_executor(
None, _probe_gateway_health
)
finally:
db.close()
except Exception:
pass
if alive:
gateway_running = True
# PID from the remote container (display only — not locally valid)
if remote_health_body:
gateway_pid = remote_health_body.get("pid")
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
# and which providers are registered so ``hermes status`` and the
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
auth_providers = [p.name for p in _list_providers()]
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
gateway_state = None
gateway_platforms: dict = {}
gateway_exit_reason = None
gateway_updated_at = None
configured_gateway_platforms: set[str] | None = None
try:
from gateway.config import load_gateway_config
# Always-public liveness + auth-gate shape. Safe for external uptime
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
status = {
"version": __version__,
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
}
gateway_config = load_gateway_config()
configured_gateway_platforms = {
platform.value for platform in gateway_config.get_connected_platforms()
}
except Exception:
configured_gateway_platforms = None
# Absolute host paths, the gateway PID, and the internal gateway health
# URL are deployment recon a liveness probe never needs. ``/api/status``
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
# network-exposed (gated) bind that means *any* unauthenticated caller
# reaches it, and leaking host metadata there contradicts the allowlist's
# own contract ("version, gateway state, active session count, and the
# dashboard auth-gate shape. No bodies, no session content, no secrets").
# Surface this detail only on a loopback / ``--insecure`` bind, where the
# dashboard is local-only and the caller is already inside the trust
# envelope — the same loopback/gated split ``should_require_auth`` draws.
if not auth_required:
status.update({
"hermes_home": str(get_hermes_home()),
"config_path": str(get_config_path()),
"env_path": str(get_env_path()),
"gateway_pid": gateway_pid,
"gateway_health_url": _GATEWAY_HEALTH_URL,
})
# Prefer the detailed health endpoint response (has full state) when the
# local runtime status file is absent or stale (cross-container).
local_runtime = read_runtime_status()
runtime = local_runtime
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
runtime = remote_health_body
# The runtime-status PID fallback validates liveness with a local
# os.kill() probe, so it must only run against the LOCAL status file —
# never the remote health body, whose PID belongs to another host and
# is display-only. (Running os.kill on a remote PID is both wrong and
# trips the test live-system guard.)
if not gateway_running and local_runtime is not None:
runtime_pid = get_runtime_status_running_pid(local_runtime)
if runtime_pid is not None:
gateway_running = True
gateway_pid = runtime_pid
return status
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
if configured_gateway_platforms is not None:
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if key in configured_gateway_platforms
}
gateway_exit_reason = runtime.get("exit_reason")
gateway_updated_at = runtime.get("updated_at")
if not gateway_running:
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
gateway_platforms = {}
elif gateway_running and remote_health_body is not None:
# The health probe confirmed the gateway is alive, but the local
# runtime status file may be stale (cross-container). Override
# stopped/None state so the dashboard shows the correct badge.
if gateway_state in {None, "stopped"}:
gateway_state = "running"
# If there was no runtime info at all but the health probe confirmed alive,
# ensure we still report the gateway as running (no shared volume scenario).
if gateway_running and gateway_state is None and remote_health_body is not None:
gateway_state = "running"
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
try:
sessions = db.list_sessions_rich(limit=50)
now = time.time()
active_sessions = sum(
1 for s in sessions
if s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
finally:
db.close()
except Exception:
pass
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
# and which providers are registered so ``hermes status`` and the
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
auth_providers = [p.name for p in _list_providers()]
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
# Always-public liveness + auth-gate shape. Safe for external uptime
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
status = {
"version": __version__,
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
}
# Absolute host paths, the gateway PID, and the internal gateway health
# URL are deployment recon a liveness probe never needs. ``/api/status``
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
# network-exposed (gated) bind that means *any* unauthenticated caller
# reaches it, and leaking host metadata there contradicts the allowlist's
# own contract ("version, gateway state, active session count, and the
# dashboard auth-gate shape. No bodies, no session content, no secrets").
# Surface this detail only on a loopback / ``--insecure`` bind, where the
# dashboard is local-only and the caller is already inside the trust
# envelope — the same loopback/gated split ``should_require_auth`` draws.
if not auth_required:
status.update({
"hermes_home": str(get_hermes_home()),
"config_path": str(get_config_path()),
"env_path": str(get_env_path()),
"gateway_pid": gateway_pid,
"gateway_health_url": _GATEWAY_HEALTH_URL,
})
return status
finally:
if status_scope is not None:
status_scope.__exit__(*sys.exc_info())
_WINDOWS_11_MIN_BUILD = 22000
@ -2095,6 +2129,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
# report liveness and exit code without shelling out to ``ps``.
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
# ``name`` → completed synthetic action result for actions the server handled
# without spawning a subprocess (for example, unsupported Docker updates).
@ -2114,6 +2149,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
if not message.endswith("\n"):
log_file.write(b"\n")
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
@ -2154,6 +2190,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
# fd per spawned action.
log_file.close()
_ACTION_RESULTS.pop(name, None)
_ACTION_COMMANDS[name] = tuple(subcommand)
_ACTION_PROCS[name] = proc
return proc
@ -2172,7 +2209,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:
return _profile_cli_args(profile) + ["gateway", verb]
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession
@ -2183,16 +2228,20 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
Returns ``(proc, reused)``.
"""
subcommand = _gateway_subcommand(profile, "restart")
existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None:
return existing, True
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
existing_command = _ACTION_COMMANDS.get("gateway-restart")
if existing_command is None or existing_command == tuple(subcommand):
return existing, True
raise RuntimeError("gateway restart already in progress for another profile")
return _spawn_hermes_action(subcommand, "gateway-restart"), False
def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
"""Best-effort gateway restart after enabling the webhook platform."""
try:
proc, reused = _spawn_gateway_restart()
proc, reused = _spawn_gateway_restart(profile)
except Exception as exc:
_log.exception("Failed to auto-restart gateway after enabling webhooks")
return {
@ -2212,10 +2261,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
@app.post("/api/gateway/restart")
async def restart_gateway():
async def restart_gateway(profile: Optional[str] = None):
"""Kick off a ``hermes gateway restart`` in the background."""
try:
proc, _reused = _spawn_gateway_restart()
proc, _reused = _spawn_gateway_restart(profile)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
@ -2635,6 +2686,7 @@ async def get_action_status(name: str, lines: int = 200):
pass
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
return {
"name": name,
@ -4346,13 +4398,16 @@ def _messaging_platform_payload(
scoped: bool = False,
) -> dict[str, Any]:
platform_id = entry["id"]
gateway_running = get_running_pid() is not None
runtime_platforms = runtime.get("platforms") if runtime else {}
runtime_platform = (
runtime_platforms.get(platform_id, {})
if isinstance(runtime_platforms, dict)
else {}
)
gateway_running = (
get_running_pid() is not None
or get_runtime_status_running_pid(runtime) is not None
)
env_vars = []
for key in entry["env_vars"]:
@ -4415,15 +4470,37 @@ def _messaging_platform_payload(
state = (
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
)
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
if not enabled:
state = "disabled"
elif not configured:
state = "not_configured"
elif gateway_running and not state:
state = "pending_restart"
elif (
not gateway_running
and not state
and runtime_gateway_state == "startup_failed"
):
state = "startup_failed"
elif not gateway_running and not state:
state = "gateway_stopped"
error_code = (
runtime_platform.get("error_code")
if isinstance(runtime_platform, dict)
else None
)
error_message = (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
)
if state == "startup_failed":
error_code = error_code or "startup_failed"
error_message = error_message or runtime_gateway_error
return {
"id": platform_id,
"name": entry["name"],
@ -4433,16 +4510,8 @@ def _messaging_platform_payload(
"configured": configured,
"gateway_running": gateway_running,
"state": state,
"error_code": (
runtime_platform.get("error_code")
if isinstance(runtime_platform, dict)
else None
),
"error_message": (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
),
"error_code": error_code,
"error_message": error_message,
"updated_at": (
runtime_platform.get("updated_at")
if isinstance(runtime_platform, dict)
@ -4732,7 +4801,7 @@ async def get_telegram_onboarding_status(pairing_id: str):
)
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
@ -4741,7 +4810,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
restart failures so the UI can fall back to the existing manual banner.
"""
try:
proc, reused = _spawn_gateway_restart()
proc, reused = _spawn_gateway_restart(profile)
except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
return {
@ -4762,7 +4831,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
):
allowed_user_ids = []
seen = set()
@ -4798,10 +4867,14 @@ async def apply_telegram_onboarding(
detail="Telegram setup is not ready yet.",
)
effective_profile = body.profile or profile
try:
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
with _profile_scope(effective_profile):
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
@ -4814,7 +4887,7 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding()
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
return {
"ok": True,
@ -4842,6 +4915,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
env_on_disk = load_env()
runtime = read_runtime_status()
return {
"env_path": str(get_env_path()),
"gateway_start_command": _gateway_display_command(profile, "start"),
"platforms": [
_messaging_platform_payload(
entry, env_on_disk, runtime, scoped=scoped_dir is not None
@ -7802,9 +7877,11 @@ async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
@app.post("/api/gateway/start")
async def start_gateway():
async def start_gateway(profile: Optional[str] = None):
try:
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway start")
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
@ -7812,9 +7889,11 @@ async def start_gateway():
@app.post("/api/gateway/stop")
async def stop_gateway():
async def stop_gateway(profile: Optional[str] = None):
try:
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway stop")
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
@ -8346,7 +8425,7 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
profile (no args, legacy behavior).
"""
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
if not requested or requested.lower() in {"current", "default"}:
return []
from hermes_cli import profiles as profiles_mod
_resolve_profile_dir(requested)
@ -9432,6 +9511,40 @@ def _profile_scope(profile: Optional[str]):
reset_hermes_home_override(token)
@contextmanager
def _config_profile_scope(profile: Optional[str]):
"""Await-safe, config-only profile scope for handlers that ``await``.
Unlike ``_profile_scope`` this touches ONLY the context-local
``set_hermes_home_override`` contextvar it does NOT swap the
process-global ``skills_tool``/``skill_manager`` module attributes.
Those globals are shared across all event-loop tasks, so holding them
across an ``await`` lets a concurrent skills request restore THIS
request's profile dir on its ``finally`` (cross-contamination). The
contextvar override is task-local and survives an ``await`` cleanly,
which is all endpoints that resolve ``get_hermes_home()`` at call time
(config, env, gateway status) actually need.
None/""/"current" means the dashboard's own profile — no override.
"""
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
yield None
return
from hermes_constants import (
set_hermes_home_override,
reset_hermes_home_override,
)
profile_dir = _resolve_profile_dir(requested)
token = set_hermes_home_override(str(profile_dir))
try:
yield profile_dir
finally:
reset_hermes_home_override(token)
class SkillToggle(BaseModel):
name: str
enabled: bool

View file

@ -461,11 +461,21 @@ _container_detected: bool | None = None
def is_container() -> bool:
"""Return True when running inside a Docker/Podman container.
"""Return True when running inside a container.
Checks ``/.dockerenv`` (Docker), ``/run/.containerenv`` (Podman),
and ``/proc/1/cgroup`` for container runtime markers. Result is
cached for the process lifetime. Import-safe no heavy deps.
Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``),
and via ``/proc/1/cgroup`` the docker/podman/lxc cgroup-v1 markers.
cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no
runtime marker, so containerd/CRI-O runtimes (the common case on
Kubernetes/k3s) were previously missed. To cover those, also check:
* ``KUBERNETES_SERVICE_HOST`` env var set in every Kubernetes pod.
* ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``.
* the same markers in ``/proc/self/mountinfo`` (cgroup-v2 fallback).
Result is cached for the process lifetime. Import-safe no heavy deps.
See: NousResearch/hermes-agent#47111
"""
global _container_detected
if _container_detected is not None:
@ -476,10 +486,26 @@ def is_container() -> bool:
if os.path.exists("/run/.containerenv"):
_container_detected = True
return True
# Kubernetes always injects this into pod containers; absent on hosts.
if os.environ.get("KUBERNETES_SERVICE_HOST"):
_container_detected = True
return True
_CGROUP_MARKERS = ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio")
try:
with open("/proc/1/cgroup", "r", encoding="utf-8") as f:
cgroup = f.read()
if "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup:
if any(marker in cgroup for marker in _CGROUP_MARKERS):
_container_detected = True
return True
except OSError:
pass
# cgroup v2: /proc/1/cgroup is just "0::/" with no marker. The container
# runtime still shows up in the mount table (overlay rootfs, runtime mount
# paths), so scan mountinfo as a last resort.
try:
with open("/proc/self/mountinfo", "r", encoding="utf-8") as f:
mountinfo = f.read()
if any(marker in mountinfo for marker in ("kubepods", "containerd", "crio")):
_container_detected = True
return True
except OSError:

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