Compare commits

..
Author SHA1 Message Date
alt-glitchandJanHranicky c97f0a6c82 refactor(apify): move Actor tools into bundled plugin
Re-shelve the three Apify tools from core into plugins/apify/, matching
the Spotify plugin pattern for optional third-party SaaS integrations.
tools/ is reserved for foundational capabilities; third-party service
integrations live in plugins/.

- plugins/apify/{__init__,tools,client}.py + plugin.yaml + README
  (kind: backend, auto-loads; registers via ctx.register_tool())
- remove apify_* from _HERMES_CORE_TOOLS in toolsets.py
  (TOOLSETS["apify"] entry kept, mirroring spotify)
- tests moved tests/tools/test_apify_tool.py -> tests/plugins/test_apify.py,
  import paths updated (34 tests pass)
- add JanHranicky to AUTHOR_MAP in scripts/release.py (CI gate)

The tools_config.py / config.py / lazy_deps.py / pyproject.toml setup +
config UX from the original commit is retained unchanged.

Co-authored-by: JanHranicky <jan.hranicky@seznam.cz>
2026-06-08 15:03:11 +05:30
JanHranicky 58e921a819 feat(apify): Actor execution tools — discover, start, collect
Cherry-picked from PR #41932 (JanHranicky). Original implementation
registered the three Apify tools as built-in core tools; the follow-up
commit moves them into a bundled plugin (plugins/apify/).

Co-authored-by: JanHranicky <jan.hranicky@seznam.cz>
2026-06-08 15:00:57 +05:30
256 changed files with 7525 additions and 27447 deletions
+6 -16
View File
@@ -59,22 +59,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Rebuild the unified catalog. The file is gitignored, so a fresh
# checkout starts without it and we want the freshest crawl in
# every deploy.
#
# This MUST be fatal. build_skills_index.py runs a health check and
# exits non-zero WITHOUT writing the output file when a source
# collapses (e.g. a GitHub API rate limit zeroes the github /
# claude-marketplace / well-known taps all at once). Letting the
# deploy continue would either (a) ship a degenerate index missing
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
# local-only catalog. Failing here keeps the last good deployment
# live (GitHub Pages serves the previous build) instead of
# publishing a broken catalog. Re-run the workflow once the
# transient rate limit clears.
python3 scripts/build_skills_index.py
# Always rebuild the file isn't committed (gitignored), so a
# fresh checkout starts without it and we want the freshest crawl
# in every deploy. Failure is non-fatal: extract-skills.py will
# fall back to the legacy snapshot cache and the Skills Hub page
# still renders, just without the latest community catalog.
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py
+8 -40
View File
@@ -55,31 +55,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -177,31 +161,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |
-1
View File
@@ -1,6 +1,5 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
+2 -4
View File
@@ -3,9 +3,7 @@
</p>
# Hermes Agent ☤
<p align="center">
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
</p>
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
@@ -55,7 +53,7 @@ If you already have Git installed, the installer detects it and uses that instea
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux.
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
After installation:
-2
View File
@@ -169,7 +169,6 @@ def init_agent(
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
@@ -281,7 +280,6 @@ def init_agent(
agent.save_trajectories = save_trajectories
agent.verbose_logging = verbose_logging
agent.quiet_mode = quiet_mode
agent.tool_progress_mode = tool_progress_mode
agent.ephemeral_system_prompt = ephemeral_system_prompt
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
agent._user_id = user_id # Platform user identifier (gateway sessions)
+20 -120
View File
@@ -73,50 +73,20 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# ── Anthropic thinking-mode classification ────────────────────────────
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
# claude-fable-5) follow the same modern contract — but they share no common
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
# stale the moment a model ships without a recognized number and silently
# routes it down the legacy manual-thinking path.
#
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
# explicit *legacy* list of the older Claude families that still require manual
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
# (future models are unlikely to regress to the older contract), so each new
# Claude release works without a code change.
#
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
# so they fall through to the legacy path automatically — exactly what those
# manual-thinking endpoints need.
# Older Claude families that DON'T support adaptive thinking (manual thinking
# with budget_tokens only). Substring-matched against the model name.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
# not in this list (4.7, 4.8, fable, future) accept xhigh.
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
"claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6",
)
def _is_claude_model(model: str | None) -> bool:
return "claude" in (model or "").lower()
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -124,8 +94,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -240,17 +208,8 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude models that use adaptive thinking (4.6+).
Defaults *unknown* Claude models to adaptive (the modern contract) and
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, …) return False so they keep the manual path.
"""
if not _is_claude_model(model):
return False
m = model.lower()
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -260,33 +219,18 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
only the 4.6 family and legacy manual-thinking models are excluded.
"""
if not _supports_adaptive_thinking(model):
return False
m = model.lower()
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 introduced this restriction; later Claude releases follow it.
Defaults unknown Claude models to forbidding sampling params (the modern
contract). The 4.6 family still accepts them, and the legacy manual-thinking
families (4.5 and older) accept them too, so both are excluded. Non-Claude
models are unaffected. Callers should omit these fields entirely rather than
passing zero/default values (the API rejects anything non-null).
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
"""
if not _is_claude_model(model):
return False
m = model.lower()
# 4.6 family is adaptive but still accepts sampling params.
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
return False
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -877,7 +821,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1220,10 +1163,7 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
# Run interactively — stdin/stdout/stderr inherited so user can interact
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):
@@ -2361,43 +2301,3 @@ def build_anthropic_kwargs(
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
return kwargs
# Keys that belong exclusively to the OpenAI Responses / Codex API shape.
# The Anthropic Messages SDK (``messages.create()`` / ``messages.stream()``)
# raises ``TypeError: ... got an unexpected keyword argument`` on any of them.
_RESPONSES_ONLY_KWARGS = frozenset(
{"instructions", "input", "store", "parallel_tool_calls"}
)
def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
"""Drop Responses-API-only keys before an Anthropic Messages SDK call.
Defensive boundary guard for #31673: under rare api_mode-flip races
(e.g. a concurrent auxiliary call mutating a shared agent between the
kwargs build and the stream dispatch), a Responses-shaped payload
carrying ``instructions=`` can reach ``messages.stream()`` /
``messages.create()``. The Anthropic SDK rejects it with a
non-retryable ``TypeError`` that nukes the whole turn and propagates
the entire fallback chain.
Mutates ``api_kwargs`` in place and returns it. When a foreign key is
present we log a WARNING so the underlying race stays visible in the
wild instead of being silently papered over.
"""
if not isinstance(api_kwargs, dict):
return api_kwargs
leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs)
if leaked:
for _key in leaked:
api_kwargs.pop(_key, None)
logger.warning(
"%sStripped Responses-only kwarg(s) %s from an Anthropic Messages "
"call (api_mode flip race — see #31673). The call will proceed; "
"this breadcrumb means a kwargs build ran under a Responses "
"api_mode while dispatch ran under anthropic_messages.",
log_prefix,
sorted(leaked),
)
return api_kwargs
-60
View File
@@ -1986,58 +1986,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"(possible upstream error or malformed SSE response)."
)
# A stream that delivered a tool call but only partial/unparseable
# JSON args splits into two very different cases:
#
# 1. Provider sent finish_reason="length" → a genuine output-cap
# truncation. Boosting max_tokens on retry is the right move.
#
# 2. Provider sent NO finish_reason (the SSE simply stopped after
# the opening "{" with no terminator and no [DONE]) → the
# upstream dropped/stalled the connection mid tool-call. This
# is NOT an output cap — the model never reported hitting one.
# Some dedicated endpoints (e.g. NVIDIA Nemotron Ultra on the
# Nous dedicated endpoint) stall for minutes during large
# tool-arg generation, then close the stream cleanly without a
# finish_reason. Stamping "length" here sends it down the
# max_tokens-boost truncation path, which retries 3× to no
# effect and finally reports the misleading "Response truncated
# due to output length limit" — the red herring this guards
# against. Route it through the partial-stream-stub path
# instead so the loop reports an honest mid-tool-call stream
# drop and fails fast rather than escalating output budget.
_tool_args_dropped_no_finish = has_truncated_tool_args and finish_reason is None
if _tool_args_dropped_no_finish:
_dropped_names = [
(tool_calls_acc[idx]["function"]["name"] or "?")
for idx in sorted(tool_calls_acc)
]
logger.warning(
"Stream ended with no finish_reason while a tool call's "
"arguments were still incomplete (tools=%s); treating as a "
"mid-tool-call stream drop, not an output-length truncation.",
_dropped_names,
)
full_reasoning = "".join(reasoning_parts) or None
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
message=mock_message,
finish_reason=FINISH_REASON_LENGTH,
)
return SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=model_name,
choices=[mock_choice],
usage=usage_obj,
_dropped_tool_names=_dropped_names or None,
)
effective_finish_reason = finish_reason or "stop"
if has_truncated_tool_args:
effective_finish_reason = "length"
@@ -2076,14 +2024,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Per-attempt diagnostic dict for the retry block to consume.
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
# Defensive: strip Responses-only kwargs (instructions, input, ...)
# that can leak in under an api_mode-flip race. The Anthropic SDK
# raises a non-retryable TypeError on them, killing the turn. See
# #31673 / sanitize_anthropic_kwargs().
from agent.anthropic_adapter import sanitize_anthropic_kwargs
sanitize_anthropic_kwargs(
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
+1 -152
View File
@@ -25,154 +25,6 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float):
return max(int(value), 0)
if isinstance(value, str):
try:
return max(int(value), 0)
except ValueError:
return 0
return 0
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
"""Translate Codex app-server token usage into Hermes accounting.
Codex app-server reports usage via thread/tokenUsage/updated as:
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
totalTokens.
Hermes' canonical prompt bucket includes uncached input + cached input.
The Codex app-server protocol does not currently expose cache-write tokens,
so that bucket remains zero on this runtime.
Even when Codex omits usage for a turn, Hermes should still count that turn
as one API call for session/status accounting.
"""
agent.session_api_calls += 1
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server api-call persistence failed (session=%s): %s",
agent.session_id, exc,
)
return {}
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
reported_total = _coerce_usage_int(usage.get("totalTokens"))
canonical_usage = CanonicalUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=0,
reasoning_tokens=reasoning_tokens,
raw_usage=usage,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = reported_total or canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"input_tokens": canonical_usage.input_tokens,
"output_tokens": canonical_usage.output_tokens,
"cache_read_tokens": canonical_usage.cache_read_tokens,
"cache_write_tokens": canonical_usage.cache_write_tokens,
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
try:
compressor.update_from_response(usage_dict)
context_window = getattr(turn, "model_context_window", None)
if isinstance(context_window, int) and context_window > 0:
compressor.context_length = context_window
except Exception:
logger.debug("codex app-server usage update failed", exc_info=True)
agent.session_prompt_tokens += prompt_tokens
agent.session_completion_tokens += completion_tokens
agent.session_total_tokens += total_tokens
agent.session_input_tokens += canonical_usage.input_tokens
agent.session_output_tokens += canonical_usage.output_tokens
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
cost_result = estimate_usage_cost(
agent.model,
canonical_usage,
provider=agent.provider,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"
if cost_result.status == "included" else None,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
agent.session_id, total_tokens, exc,
)
return {
**usage_dict,
"last_prompt_tokens": prompt_tokens,
"estimated_cost_usd": float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
"cost_status": cost_result.status,
"cost_source": cost_result.source,
}
def run_codex_app_server_turn(
agent,
*,
@@ -268,8 +120,6 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
# Now check the skill nudge AFTER iters were incremented — same
# pattern the chat_completions path uses (line ~15432).
@@ -314,13 +164,12 @@ def run_codex_app_server_turn(
return {
"final_response": turn.final_text,
"messages": messages,
"api_calls": api_calls,
"api_calls": 1, # one app-server "turn" maps to one logical API call
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
}
-2
View File
@@ -290,7 +290,6 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -483,7 +482,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
+375 -18
View File
@@ -4196,26 +4196,383 @@ def run_conversation(
messages.append({"role": "assistant", "content": final_response})
break
# Post-loop turn finalization extracted to agent/turn_finalizer.finalize_turn
# (god-file decomposition Phase 1 step 4). Behavior-neutral: the assembled
# result dict is returned exactly as before.
from agent.turn_finalizer import finalize_turn
return finalize_turn(
agent,
final_response=final_response,
api_call_count=api_call_count,
interrupted=interrupted,
failed=failed,
messages=messages,
conversation_history=conversation_history,
effective_task_id=effective_task_id,
turn_id=turn_id,
user_message=user_message,
original_user_message=original_user_message,
_should_review_memory=_should_review_memory,
_turn_exit_reason=_turn_exit_reason,
if final_response is None and (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
):
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
agent._emit_status(
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— asking model to summarise"
)
if not agent.quiet_mode:
agent._safe_print(
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— requesting summary..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
# If running as a kanban worker, signal the dispatcher that the
# worker could not complete (rather than treating it as a
# protocol violation). The agent loop strips tools before calling
# _handle_max_iterations, so the model cannot call kanban_block
# itself — we must do it on its behalf.
#
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the
# ``consecutive_failures`` counter and the dispatcher's
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
# a task whose worker keeps exhausting its budget would block
# silently each run, get auto-promoted by the operator (or never
# surface), and re-block in an endless loop with no signal.
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
from hermes_cli import kanban_db as _kb
_conn = _kb.connect()
try:
_kb._record_task_failure(
_conn,
_kanban_task,
error=(
f"Iteration budget exhausted "
f"({api_call_count}/{agent.max_iterations}) — "
"task could not complete within the allowed "
"iterations"
),
outcome="timed_out",
release_claim=True,
end_run=True,
event_payload_extra={
"budget_used": api_call_count,
"budget_max": agent.max_iterations,
},
)
logger.info(
"recorded budget-exhausted failure for task %s (%d/%d)",
_kanban_task, api_call_count, agent.max_iterations,
)
finally:
try:
_conn.close()
except Exception:
pass
except Exception:
logger.warning(
"Failed to record budget-exhausted failure for task %s",
_kanban_task,
exc_info=True,
)
# Determine if conversation completed successfully
completed = (
final_response is not None
and api_call_count < agent.max_iterations
and not failed
)
# Save trajectory if enabled. ``user_message`` may be a multimodal
# list of parts; the trajectory format wants a plain string.
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
# Clean up VM and browser for this task after conversation completes
agent._cleanup_task_resources(effective_task_id)
# Persist session to both JSON log and SQLite only after private retry
# scaffolding has been removed. Otherwise a later user "continue" turn
# can replay assistant("(empty)") / recovery nudges and fall into the
# same empty-response loop again.
agent._drop_trailing_empty_response_scaffolding(messages)
agent._persist_session(messages, conversation_history)
# ── Turn-exit diagnostic log ─────────────────────────────────────
# Always logged at INFO so agent.log captures WHY every turn ended.
# When the last message is a tool result (agent was mid-work), log
# at WARNING — this is the "just stops" scenario users report.
_last_msg_role = messages[-1].get("role") if messages else None
_last_tool_name = None
if _last_msg_role == "tool":
# Walk back to find the assistant message with the tool call
for _m in reversed(messages):
if _m.get("role") == "assistant" and _m.get("tool_calls"):
_tcs = _m["tool_calls"]
if _tcs and isinstance(_tcs[0], dict):
_last_tool_name = _tcs[-1].get("function", {}).get("name")
break
_turn_tool_count = sum(
1 for m in messages
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
)
_resp_len = len(final_response) if final_response else 0
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
_diag_msg = (
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
)
_diag_args = (
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
_budget_used, _budget_max,
_turn_tool_count, _last_msg_role, _resp_len,
agent.session_id or "none",
)
if _last_msg_role == "tool" and not interrupted:
# Agent was mid-work — this is the "just stops" case.
logger.warning(
"Turn ended with pending tool result (agent may appear stuck). "
+ _diag_msg + " last_tool=%s",
*_diag_args, _last_tool_name,
)
else:
logger.info(_diag_msg, *_diag_args)
# File-mutation verifier footer.
# If one or more ``write_file`` / ``patch`` calls failed during this
# turn and were never superseded by a successful write to the same
# path, append an advisory footer to the assistant response. This
# catches the specific case — reported by Ben Eng (#15524-adjacent)
# — where a model issues a batch of parallel patches, half of them
# fail with "Could not find old_string", and the model summarises
# the turn claiming every file was edited. The user then has to
# manually run ``git status`` to catch the lie. With this footer
# the truth is surfaced on every turn, so over-claiming is
# structurally impossible past the model.
#
# Gate: only applied when a real text response exists for this
# turn and the user didn't interrupt. Empty/interrupted turns
# already have other surface text that shouldn't be augmented.
if final_response and not interrupted:
try:
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
if _failed and agent._file_mutation_verifier_enabled():
footer = agent._format_file_mutation_failure_footer(_failed)
if footer:
final_response = final_response.rstrip() + "\n\n" + footer
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
# Turn-completion explainer.
# When a turn ends abnormally after substantive work — empty content
# after retries, a partial/truncated stream, a still-pending tool
# result, or an iteration/budget limit — the user otherwise gets a
# blank or fragmentary response box with no consolidated reason why
# the agent stopped (#34452). Surface a single user-visible
# explanation derived from ``_turn_exit_reason``, mirroring the
# file-mutation verifier footer pattern above.
#
# Gate carefully so healthy turns stay quiet:
# - ``text_response(...)`` exits never produce an explanation
# (handled inside the formatter), so a terse ``Done.`` is silent.
# - We only ACT when there is no genuinely usable reply this turn:
# an empty response, the "(empty)" terminal sentinel, or a
# suspiciously short partial fragment with no terminating
# punctuation (e.g. "The"). A real short answer keeps its text.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
# A short fragment that is not a normal text_response exit
# and lacks sentence-ending punctuation is treated as a
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
)
if _is_empty_terminal or _is_partial_fragment:
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment, append the reason so
# the user sees both what arrived and why it
# stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
# Plugin hook: transform_llm_output
# Fired once per turn after the tool-calling loop completes.
# Plugins can transform the LLM's output text before it's returned.
# First hook to return a string wins; None/empty return leaves text unchanged.
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
_response_transformed = True
break # First non-empty string wins
except Exception as exc:
logger.warning("transform_llm_output hook failed: %s", exc)
# Plugin hook: post_llm_call
# Fired once per turn after the tool-calling loop completes.
# Plugins can use this to persist conversation data (e.g. sync
# to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
assistant_response=final_response,
conversation_history=list(messages),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Extract reasoning from the CURRENT turn only. Walk backwards
# but stop at the user message that started this turn — anything
# earlier is from a prior turn and must not leak into the reasoning
# box (confusing stale display; #17055). Within the current turn
# we still want the *most recent* non-empty reasoning: many
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
# reasoning on the tool-call step and leave the final-answer step
# with reasoning=None, so picking only the last assistant would
# silently drop legitimate same-turn reasoning.
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
# Build result with interrupt info if applicable
result = {
"final_response": final_response,
"last_reasoning": last_reasoning,
"messages": messages,
"api_calls": api_call_count,
"completed": completed,
"turn_exit_reason": _turn_exit_reason,
"failed": failed,
"partial": False, # True only when stopped due to invalid tool calls
"interrupted": interrupted,
"response_transformed": _response_transformed,
"response_previewed": getattr(agent, "_response_was_previewed", False),
"model": agent.model,
"provider": agent.provider,
"base_url": agent.base_url,
"input_tokens": agent.session_input_tokens,
"output_tokens": agent.session_output_tokens,
"cache_read_tokens": agent.session_cache_read_tokens,
"cache_write_tokens": agent.session_cache_write_tokens,
"reasoning_tokens": agent.session_reasoning_tokens,
"prompt_tokens": agent.session_prompt_tokens,
"completion_tokens": agent.session_completion_tokens,
"total_tokens": agent.session_total_tokens,
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
# If a /steer landed after the final assistant turn (no more tool
# batches to drain into), hand it back to the caller so it can be
# delivered as the next user turn instead of being silently lost.
_leftover_steer = agent._drain_pending_steer()
if _leftover_steer:
result["pending_steer"] = _leftover_steer
agent._response_was_previewed = False
# Include interrupt message if one triggered the interrupt
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
# Clear interrupt state after handling
agent.clear_interrupt()
# Clear stream callback so it doesn't leak into future calls
agent._stream_callback = None
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
_should_review_skills = False
if (agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names):
_should_review_skills = True
agent._iters_since_skill = 0
# External memory provider: sync the completed turn + queue next prefetch.
agent._sync_external_memory_for_turn(
original_user_message=original_user_message,
final_response=final_response,
interrupted=interrupted,
messages=messages,
)
# Background memory/skill review — runs AFTER the response is delivered
# so it never competes with the user's task for model attention.
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
try:
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
except Exception:
pass # Background review is best-effort
# Note: Memory provider on_session_end() + shutdown_all() are NOT
# called here — run_conversation() is called once per user message in
# multi-turn sessions. Shutting down after every turn would kill the
# provider before the second message. Actual session-end cleanup is
# handled by the CLI (atexit / /reset) and gateway (session expiry /
# _reset_session).
# Plugin hook: on_session_end
# Fired at the very end of every run_conversation call.
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
completed=completed,
interrupted=interrupted,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
return result
__all__ = ["run_conversation"]
-1
View File
@@ -91,7 +91,6 @@ AUTH_TYPE_OAUTH = "oauth"
AUTH_TYPE_API_KEY = "api_key"
SOURCE_MANUAL = "manual"
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"
STRATEGY_FILL_FIRST = "fill_first"
STRATEGY_ROUND_ROBIN = "round_robin"
-3
View File
@@ -262,7 +262,6 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -311,7 +310,6 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -349,7 +347,6 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
-50
View File
@@ -141,8 +141,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -970,16 +968,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
) or (
# LM Studio / llama.cpp / some OpenAI-compatible servers:
# "This model's maximum context length is 65536 tokens. However, you
# requested 65536 output tokens and your prompt contains 77409
# characters ..."
# The "requested N output tokens" phrasing means the OUTPUT cap is the
# problem (the input itself fits) — reduce max_tokens, don't compress.
"maximum context length" in error_lower
and "requested" in error_lower
and "output tokens" in error_lower
)
if not is_output_cap_error:
return None
@@ -1011,22 +999,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
if _available >= 1:
return _available
# LM Studio / llama.cpp style: context window is reported in tokens but the
# prompt size is reported in CHARACTERS, e.g.
# "maximum context length is 65536 tokens ... your prompt contains 77409
# characters ...".
# Estimate the input tokens conservatively (~3 chars/token, which
# over-reserves the input so the retried output cap stays safely inside the
# window) and leave the remainder of the window for output.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
return None
@@ -1812,28 +1784,6 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
# must win over both models.dev (step 5g) and the hardcoded family catch-all
# (step 8). Before this branch, an OpenRouter selection set
# effective_provider="openrouter", which (a) made the models.dev lookup miss
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
# the dedicated Nous/Copilot/GMI branches above.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
-2
View File
@@ -274,7 +274,6 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -526,7 +525,6 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
-1
View File
@@ -74,7 +74,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"
+1 -1
View File
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif getattr(agent, "tool_progress_mode", "all") != "off":
elif not agent.quiet_mode:
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
-1
View File
@@ -378,7 +378,6 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (
@@ -72,9 +72,6 @@ class TurnResult:
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -504,7 +501,6 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
_apply_token_usage_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -540,8 +536,6 @@ class CodexAppServerSession:
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
# approval (the approval params themselves don't carry the
@@ -808,30 +802,6 @@ class CodexAppServerSession:
return cached
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex app-server token usage updates for caller accounting.
Codex does not put token usage on turn/completed. It emits a separate
thread/tokenUsage/updated notification containing cumulative totals and
the latest turn breakdown.
"""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
params = note.get("params") or {}
token_usage = params.get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
total = token_usage.get("total")
if isinstance(last, dict):
result.token_usage_last = dict(last)
if isinstance(total, dict):
result.token_usage_total = dict(total)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.
-428
View File
@@ -1,428 +0,0 @@
"""Post-loop turn finalization for ``run_conversation``.
Extracted from ``agent/conversation_loop.py`` as part of the god-file
decomposition campaign (``~/.hermes/plans/god-file-decomposition.md``, Phase 1
step 4 the post-loop ``TurnFinalizer`` seam). ``run_conversation``'s tail
(everything after the main tool-calling ``while`` loop) is lifted here verbatim:
budget-exhaustion summary, trajectory save, session persist, turn diagnostics,
response transforms, result-dict assembly, steer drain, and the memory/skill
review trigger.
Behavior-neutral: the body is moved unchanged. All ``agent.*`` side effects fire
exactly as before; only the post-loop *locals* are passed in as keyword args, and
the assembled ``result`` dict is returned to ``run_conversation`` which returns it
to the caller. The function is synchronous with a single return mirroring the
region it replaces (no awaits, no early returns).
Module ``logger`` is imported lazily inside the body (``from
agent.conversation_loop import logger``) so this module never imports
``agent.conversation_loop`` at import time -> no import cycle, and the log records
keep the exact logger name (``"agent.conversation_loop"``).
"""
from __future__ import annotations
import os
from agent.codex_responses_adapter import _summarize_user_message_for_log
def finalize_turn(
agent,
*,
final_response,
api_call_count,
interrupted,
failed,
messages,
conversation_history,
effective_task_id,
turn_id,
user_message,
original_user_message,
_should_review_memory,
_turn_exit_reason,
):
"""Run the post-loop finalization and return the turn ``result`` dict.
Lifted verbatim from ``run_conversation`` (the region after the main agent
loop). See module docstring.
"""
from agent.conversation_loop import logger
if final_response is None and (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
):
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
agent._emit_status(
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— asking model to summarise"
)
if not agent.quiet_mode:
agent._safe_print(
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— requesting summary..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
# If running as a kanban worker, signal the dispatcher that the
# worker could not complete (rather than treating it as a
# protocol violation). The agent loop strips tools before calling
# _handle_max_iterations, so the model cannot call kanban_block
# itself — we must do it on its behalf.
#
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the
# ``consecutive_failures`` counter and the dispatcher's
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
# a task whose worker keeps exhausting its budget would block
# silently each run, get auto-promoted by the operator (or never
# surface), and re-block in an endless loop with no signal.
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
from hermes_cli import kanban_db as _kb
_conn = _kb.connect()
try:
_kb._record_task_failure(
_conn,
_kanban_task,
error=(
f"Iteration budget exhausted "
f"({api_call_count}/{agent.max_iterations}) — "
"task could not complete within the allowed "
"iterations"
),
outcome="timed_out",
release_claim=True,
end_run=True,
event_payload_extra={
"budget_used": api_call_count,
"budget_max": agent.max_iterations,
},
)
logger.info(
"recorded budget-exhausted failure for task %s (%d/%d)",
_kanban_task, api_call_count, agent.max_iterations,
)
finally:
try:
_conn.close()
except Exception:
pass
except Exception:
logger.warning(
"Failed to record budget-exhausted failure for task %s",
_kanban_task,
exc_info=True,
)
# Determine if conversation completed successfully
completed = (
final_response is not None
and api_call_count < agent.max_iterations
and not failed
)
# Save trajectory if enabled. ``user_message`` may be a multimodal
# list of parts; the trajectory format wants a plain string.
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
# Clean up VM and browser for this task after conversation completes
agent._cleanup_task_resources(effective_task_id)
# Persist session to both JSON log and SQLite only after private retry
# scaffolding has been removed. Otherwise a later user "continue" turn
# can replay assistant("(empty)") / recovery nudges and fall into the
# same empty-response loop again.
agent._drop_trailing_empty_response_scaffolding(messages)
agent._persist_session(messages, conversation_history)
# ── Turn-exit diagnostic log ─────────────────────────────────────
# Always logged at INFO so agent.log captures WHY every turn ended.
# When the last message is a tool result (agent was mid-work), log
# at WARNING — this is the "just stops" scenario users report.
_last_msg_role = messages[-1].get("role") if messages else None
_last_tool_name = None
if _last_msg_role == "tool":
# Walk back to find the assistant message with the tool call
for _m in reversed(messages):
if _m.get("role") == "assistant" and _m.get("tool_calls"):
_tcs = _m["tool_calls"]
if _tcs and isinstance(_tcs[0], dict):
_last_tool_name = _tcs[-1].get("function", {}).get("name")
break
_turn_tool_count = sum(
1 for m in messages
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
)
_resp_len = len(final_response) if final_response else 0
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
_diag_msg = (
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
)
_diag_args = (
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
_budget_used, _budget_max,
_turn_tool_count, _last_msg_role, _resp_len,
agent.session_id or "none",
)
if _last_msg_role == "tool" and not interrupted:
# Agent was mid-work — this is the "just stops" case.
logger.warning(
"Turn ended with pending tool result (agent may appear stuck). "
+ _diag_msg + " last_tool=%s",
*_diag_args, _last_tool_name,
)
else:
logger.info(_diag_msg, *_diag_args)
# File-mutation verifier footer.
# If one or more ``write_file`` / ``patch`` calls failed during this
# turn and were never superseded by a successful write to the same
# path, append an advisory footer to the assistant response. This
# catches the specific case — reported by Ben Eng (#15524-adjacent)
# — where a model issues a batch of parallel patches, half of them
# fail with "Could not find old_string", and the model summarises
# the turn claiming every file was edited. The user then has to
# manually run ``git status`` to catch the lie. With this footer
# the truth is surfaced on every turn, so over-claiming is
# structurally impossible past the model.
#
# Gate: only applied when a real text response exists for this
# turn and the user didn't interrupt. Empty/interrupted turns
# already have other surface text that shouldn't be augmented.
if final_response and not interrupted:
try:
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
if _failed and agent._file_mutation_verifier_enabled():
footer = agent._format_file_mutation_failure_footer(_failed)
if footer:
final_response = final_response.rstrip() + "\n\n" + footer
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
# Turn-completion explainer.
# When a turn ends abnormally after substantive work — empty content
# after retries, a partial/truncated stream, a still-pending tool
# result, or an iteration/budget limit — the user otherwise gets a
# blank or fragmentary response box with no consolidated reason why
# the agent stopped (#34452). Surface a single user-visible
# explanation derived from ``_turn_exit_reason``, mirroring the
# file-mutation verifier footer pattern above.
#
# Gate carefully so healthy turns stay quiet:
# - ``text_response(...)`` exits never produce an explanation
# (handled inside the formatter), so a terse ``Done.`` is silent.
# - We only ACT when there is no genuinely usable reply this turn:
# an empty response, the "(empty)" terminal sentinel, or a
# suspiciously short partial fragment with no terminating
# punctuation (e.g. "The"). A real short answer keeps its text.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
# A short fragment that is not a normal text_response exit
# and lacks sentence-ending punctuation is treated as a
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
)
if _is_empty_terminal or _is_partial_fragment:
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment, append the reason so
# the user sees both what arrived and why it
# stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
# Plugin hook: transform_llm_output
# Fired once per turn after the tool-calling loop completes.
# Plugins can transform the LLM's output text before it's returned.
# First hook to return a string wins; None/empty return leaves text unchanged.
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
_response_transformed = True
break # First non-empty string wins
except Exception as exc:
logger.warning("transform_llm_output hook failed: %s", exc)
# Plugin hook: post_llm_call
# Fired once per turn after the tool-calling loop completes.
# Plugins can use this to persist conversation data (e.g. sync
# to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
assistant_response=final_response,
conversation_history=list(messages),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Extract reasoning from the CURRENT turn only. Walk backwards
# but stop at the user message that started this turn — anything
# earlier is from a prior turn and must not leak into the reasoning
# box (confusing stale display; #17055). Within the current turn
# we still want the *most recent* non-empty reasoning: many
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
# reasoning on the tool-call step and leave the final-answer step
# with reasoning=None, so picking only the last assistant would
# silently drop legitimate same-turn reasoning.
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
# Build result with interrupt info if applicable
result = {
"final_response": final_response,
"last_reasoning": last_reasoning,
"messages": messages,
"api_calls": api_call_count,
"completed": completed,
"turn_exit_reason": _turn_exit_reason,
"failed": failed,
"partial": False, # True only when stopped due to invalid tool calls
"interrupted": interrupted,
"response_transformed": _response_transformed,
"response_previewed": getattr(agent, "_response_was_previewed", False),
"model": agent.model,
"provider": agent.provider,
"base_url": agent.base_url,
"input_tokens": agent.session_input_tokens,
"output_tokens": agent.session_output_tokens,
"cache_read_tokens": agent.session_cache_read_tokens,
"cache_write_tokens": agent.session_cache_write_tokens,
"reasoning_tokens": agent.session_reasoning_tokens,
"prompt_tokens": agent.session_prompt_tokens,
"completion_tokens": agent.session_completion_tokens,
"total_tokens": agent.session_total_tokens,
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
# If a /steer landed after the final assistant turn (no more tool
# batches to drain into), hand it back to the caller so it can be
# delivered as the next user turn instead of being silently lost.
_leftover_steer = agent._drain_pending_steer()
if _leftover_steer:
result["pending_steer"] = _leftover_steer
agent._response_was_previewed = False
# Include interrupt message if one triggered the interrupt
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
# Clear interrupt state after handling
agent.clear_interrupt()
# Clear stream callback so it doesn't leak into future calls
agent._stream_callback = None
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
_should_review_skills = False
if (agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names):
_should_review_skills = True
agent._iters_since_skill = 0
# External memory provider: sync the completed turn + queue next prefetch.
agent._sync_external_memory_for_turn(
original_user_message=original_user_message,
final_response=final_response,
interrupted=interrupted,
messages=messages,
)
# Background memory/skill review — runs AFTER the response is delivered
# so it never competes with the user's task for model attention.
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
try:
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
except Exception:
pass # Background review is best-effort
# Note: Memory provider on_session_end() + shutdown_all() are NOT
# called here — run_conversation() is called once per user message in
# multi-turn sessions. Shutting down after every turn would kill the
# provider before the second message. Actual session-end cleanup is
# handled by the CLI (atexit / /reset) and gateway (session expiry /
# _reset_session).
# Plugin hook: on_session_end
# Fired at the very end of every run_conversation call.
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
completed=completed,
interrupted=interrupted,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
return result
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 674 KiB

+8 -104
View File
@@ -119,20 +119,6 @@ if (REMOTE_DISPLAY_REASON) {
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
)
}
// Keep the renderer running at full speed while the window is in the background
// or occluded. The chat transcript streams to screen through a
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
// for backgrounded/occluded renderers, so without these the live answer stalls
// whenever the window loses focus (switching to your editor mid-turn, detached
// devtools, another window covering it) and only paints on refocus or refresh.
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
// these process-level switches additionally stop Chromium from backgrounding or
// occlusion-throttling the renderer. Must run before app `ready`.
app.commandLine.appendSwitch('disable-renderer-backgrounding')
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
app.commandLine.appendSwitch('disable-background-timer-throttling')
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Build-time install stamp -- the git ref this .exe was built against.
@@ -3913,12 +3899,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@@ -3944,7 +3928,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteTokenSet: Boolean(remoteToken),
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
@@ -4295,31 +4279,20 @@ async function teardownPrimaryBackendAndWait() {
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
await waitForBackendExit(dying)
}
async function waitForBackendExit(child, timeoutMs = 5000) {
if (!child) {
return
}
if (child.exitCode !== null || child.signalCode !== null) {
if (!dying) {
return
}
await new Promise(resolve => {
const timer = setTimeout(() => {
try {
if (IS_WINDOWS && Number.isInteger(child.pid)) {
forceKillProcessTree(child.pid)
} else {
child.kill('SIGKILL')
}
dying.kill('SIGKILL')
} catch {
// Already gone.
}
resolve()
}, timeoutMs)
child.once('exit', () => {
}, 5000)
dying.once('exit', () => {
clearTimeout(timer)
resolve()
})
@@ -4511,70 +4484,12 @@ function stopPoolBackend(profile) {
}
}
async function teardownPoolBackendAndWait(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
} catch {
// Already gone.
}
}
await waitForBackendExit(entry.process)
}
function stopAllPoolBackends() {
for (const profile of [...backendPool.keys()]) {
stopPoolBackend(profile)
}
}
function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
return
}
if (profile === primaryProfileKey()) {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return
}
await teardownPoolBackendAndWait(profile)
}
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
@@ -4772,16 +4687,7 @@ function createWindow() {
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
devTools: true
}
})
@@ -5193,8 +5099,6 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+122 -170
View File
@@ -76,9 +76,6 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -127,6 +124,7 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -143,25 +141,24 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const ordered: T[] = []
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
ordered.push(item)
out.push(item)
seen.add(id)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return fresh.length ? [...fresh, ...ordered] : ordered
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -174,15 +171,17 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return [...fresh, ...retained]
return next
}
function sameIds(left: string[], right: string[]) {
@@ -252,6 +251,43 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -273,7 +309,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -287,7 +322,6 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -311,9 +345,6 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -333,8 +364,6 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingOpen, setMessagingOpen] = useState<Record<string, boolean>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -500,12 +529,24 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -523,64 +564,6 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -627,28 +610,37 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = agentSessions
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
@@ -669,6 +661,25 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -891,7 +902,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -931,46 +942,6 @@ export function ChatSidebar({
/>
)}
{contentVisible && showSessionSections && !trimmedQuery &&
messagingGroups.map(group => (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex max-h-56 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={null}
footer={
group.hasMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => loadMoreForMessaging(group.sourceId)}
step={Math.max(0, group.total - group.sessions.length)}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() =>
setMessagingOpen(prev => ({ ...prev, [group.sourceId]: prev[group.sourceId] === false }))
}
onTogglePin={pinSession}
open={messagingOpen[group.sourceId] !== false}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={group.sessions}
workingSessionIdSet={workingSessionIdSet}
/>
))}
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
@@ -1001,10 +972,9 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -1012,7 +982,6 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: Sid
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1075,14 +1044,6 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1104,7 +1065,6 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1131,7 +1091,6 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1250,14 +1209,7 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -2,15 +2,12 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -70,11 +67,6 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -187,15 +179,6 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
+8 -82
View File
@@ -14,12 +14,6 @@ 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 {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -52,12 +46,9 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
$messagingSessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -67,9 +58,6 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -132,15 +120,6 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
@@ -151,18 +130,12 @@ function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
}
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, the
// actively-viewed chat (its "working" flag clears a beat before the aggregator
// sees the persisted row), and sessions whose turn just settled (same race, but
// for a chat the user has already navigated away from). Pass `scope` to only
// keep the active row when it belongs to the profile being paged.
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([
...$workingSessionIds.get(),
...$pinnedSessionIds.get(),
...getRecentlySettledSessionIds()
])
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
@@ -300,51 +273,6 @@ export function DesktopController() {
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
// seeds every platform; the sidebar splits the rows per source.
const refreshMessagingSessions = useCallback(async () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})
// Drop any non-messaging source the broad exclude didn't catch (custom
// sources) — those stay in local recents, not a platform section.
const rows = result.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])
// Page a single platform's section independently (mirrors the per-profile
// pager): fetch that source's next window and merge it back in place, leaving
// every other platform's rows untouched. Resolves the platform's exact total.
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
const loaded = $messagingSessions.get().filter(inPlatform).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])
const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -381,7 +309,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -397,8 +325,7 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -413,7 +340,7 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
const keep = sessionsToKeep(key)
@@ -770,7 +697,6 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -18,7 +18,6 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
$newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
@@ -107,10 +106,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
// Match the sidebar New Session button. A plain keyboard new chat should
// target the current live profile, not a stale per-profile quick-create
// selection from a prior action.
$newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
@@ -14,7 +14,6 @@ import {
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
@@ -614,9 +613,6 @@ export function useMessageStream({
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
@@ -4,8 +4,6 @@ import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { $connection } from '@/store/session'
import type { ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
@@ -44,10 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@@ -55,20 +50,16 @@ function Harness({
onReady,
onSeedState,
refreshSessions,
requestGateway,
storedSessionId
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
@@ -323,198 +314,3 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
// After sleep/wake the gateway's in-memory session table is cleared, so the
// first prompt.submit with the stale runtime id fails. The hook resumes the
// durable stored id (which survives gateway restarts), gets a fresh live id,
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message after wake')
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
// submitText swallows the error into an inline bubble and returns false.
expect(await handle!.submitText('message')).toBe(false)
// No resume attempt for a non-recoverable error.
expect(calls).not.toContain('session.resume')
})
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
// short-circuits — no resume is attempted and the error surfaces normally.
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
})
@@ -47,7 +47,6 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
@@ -104,20 +103,6 @@ async function readImageForRemoteAttach(
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -227,114 +212,62 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncAttachmentsForSubmit = useCallback(
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
// Already-synced or pathless refs (terminal, url, etc.) pass through.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
if (attachment.kind === 'image') {
let result: ImageAttachResponse
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = await readImageForRemoteAttach(attachment.path)
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
if (!result.attached) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
const attachedPath = result.path || attachment.path
const nextAttachment: ComposerAttachment = {
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
if (updateComposerAttachments) {
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
if (attachment.kind === 'file') {
// Non-image file refs are @file: paths the gateway reads with its file
// tools. On a remote gateway the desktop path doesn't exist there, so
// upload the bytes; the gateway stages them into the session workspace
// and hands back a workspace-relative ref that actually resolves.
// Local mode can pass the path directly (gateway shares this disk).
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
if (remote && !dataUrl) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
session_id: sessionId,
path: attachment.path,
name: attachment.label || pathLabel(attachment.path),
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
refText: result.ref_text,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway]
)
@@ -345,42 +278,35 @@ export function usePromptActions({
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
const text =
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
const userMessage: ChatMessage = {
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
}
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -397,7 +323,7 @@ export function usePromptActions({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, buildUserMessage()],
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
@@ -410,18 +336,6 @@ export function usePromptActions({
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
@@ -452,7 +366,7 @@ export function usePromptActions({
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
setMessages(current => [...current, userMessage])
}
if (!sessionId) {
@@ -478,46 +392,10 @@ export function usePromptActions({
}
try {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
await syncImageAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
} else {
submitErr = firstErr
}
}
if (submitErr !== null) {
throw submitErr
}
await requestGateway('prompt.submit', { session_id: sessionId, text })
if (usingComposerAttachments) {
clearComposerAttachments()
@@ -564,7 +442,7 @@ export function usePromptActions({
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
syncImageAttachmentsForSubmit,
updateSessionState
]
)
@@ -84,60 +84,6 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// A create/stream race nulls selected/active but the route stays on the
// session and freshDraftReady is false (NOT a new-chat transition).
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -187,72 +133,4 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
it('resumes the selected route again when the gateway reconnects', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="closed"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
@@ -56,19 +56,13 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
// stays false until the first effect run, so a session that mounts with the gateway
// already open is not mistaken for "became open" and does not double-resume with the
// pathname-driven initial resume below.
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -83,33 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
// Self-heal a desynced view: the route points at a session that isn't the
// loaded one. A create/stream race can leave selected/active null while
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
// an empty transcript even though the turn completed and persisted). The
// pathname didn't change, so the normal gate would skip and the view stays
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
// resume entry, so this can't loop; the resume's cached fast-path restores
// the already-streamed messages without a refetch.
//
// Crucially this must NOT fire during a /:sid -> /new transition, where
// startFreshSessionDraft nulls selected/active one render before the
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
// guard against a transient /:sid re-resume during "new chat" state clears
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
const shouldResume = pathnameChanged || gatewayBecameOpen
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
// `alreadyActive`: the cached runtime id can be stale once the gateway
// rebinds/reaps the session on its side, and trusting it strands Desktop on
// a dead id ("session not found"). Otherwise keep skipping when already active.
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -150,29 +150,6 @@ export function useSessionStateCache({
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
// throttles `requestAnimationFrame` to ~0 while the window is
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
// (cancelling any in-flight RAF, since we're about to publish the latest
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
flushPendingViewState()
return
}
if (viewSyncRafRef.current !== null) {
return
}
@@ -6,7 +6,6 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
@@ -58,17 +57,8 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
const activeProfileName =
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
@@ -108,50 +98,43 @@ export function AppearanceSettings() {
<ListRow
below={
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
}
description={a.themeDesc}
title={a.themeTitle}
@@ -27,7 +27,6 @@ import { $previewServerRestartStatus } from '@/store/preview'
import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
@@ -41,14 +40,7 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import {
$backendUpdateApply,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
@@ -105,10 +97,7 @@ export function useStatusbarItems({
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
const backendUpdateApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
@@ -205,19 +194,18 @@ export function useStatusbarItems({
? 'text-amber-600 hover:text-amber-600'
: 'text-destructive hover:text-destructive'
const clientVersionItem = useMemo<StatusbarItem>(() => {
const versionItem = useMemo<StatusbarItem>(() => {
const appVersion = desktopVersion?.appVersion
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const remote = connection?.mode === 'remote'
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
? updateApply.stage === 'restart'
? `${base} · ${copy.restart}`
: `${base} · ${copy.update}`
: `${base}${behindHint}`
const tooltip = [
@@ -232,18 +220,17 @@ export function useStatusbarItems({
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
detail: appVersion && sha && !applying && !remote ? sha : undefined,
detail: appVersion && sha && !applying ? sha : undefined,
hidden: !appVersion && !sha,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-client',
id: 'version',
label,
onSelect: () => openUpdateOverlayFor('client'),
onSelect: () => setUpdateOverlayOpen(true),
title: tooltip || undefined,
variant: 'action'
}
}, [
desktopVersion?.appVersion,
connection?.mode,
copy,
updateApply.applying,
updateApply.message,
@@ -253,50 +240,6 @@ export function useStatusbarItems({
updateStatus?.currentSha
])
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
const backendVersion = statusSnapshot?.version
const behind = backendUpdateStatus?.behind ?? 0
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
const base = copy.backendLabel(backendVersion ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
: `${base}${behindHint}`
const tooltip = [
applying ? backendUpdateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
backendVersion && copy.backendVersion(backendVersion)
]
.filter(Boolean)
.join(' · ')
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
hidden: !backendVersion,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-backend',
label,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
variant: 'action'
}
}, [
connection?.mode,
statusSnapshot?.version,
backendUpdateStatus?.behind,
backendUpdateApply.applying,
backendUpdateApply.message,
backendUpdateApply.stage,
copy
])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
@@ -442,8 +385,7 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
versionItem
],
[
busy,
@@ -459,8 +401,7 @@ export function useStatusbarItems({
showYoloToggle,
toggleYolo,
turnStartedAt,
clientVersionItem,
backendVersionItem,
versionItem,
yoloActive
]
)
-14
View File
@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string
+14 -43
View File
@@ -12,19 +12,12 @@ import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$updateApply,
$updateChecking,
$updateOverlayOpen,
$updateOverlayTarget,
$updateStatus,
applyBackendUpdate,
applyUpdates,
checkBackendUpdates,
checkUpdates,
resetUpdateApplyState,
setUpdateOverlayOpen,
@@ -37,27 +30,15 @@ function totalItems(groups: readonly CommitGroup[]) {
export function UpdatesOverlay() {
const open = useStore($updateOverlayOpen)
const target = useStore($updateOverlayTarget)
const clientStatus = useStore($updateStatus)
const clientChecking = useStore($updateChecking)
const clientApply = useStore($updateApply)
const backendStatus = useStore($backendUpdateStatus)
const backendChecking = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const isBackend = target === 'backend'
const status = isBackend ? backendStatus : clientStatus
const checking = isBackend ? backendChecking : clientChecking
const apply = isBackend ? backendApply : clientApply
const check = isBackend ? checkBackendUpdates : checkUpdates
const install = isBackend ? applyBackendUpdate : applyUpdates
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
const apply = useStore($updateApply)
useEffect(() => {
if (open && !status && !checking) {
void check()
void checkUpdates()
}
}, [check, checking, open, status])
}, [checking, open, status])
const behind = status?.behind ?? 0
@@ -83,7 +64,7 @@ export function UpdatesOverlay() {
}
const handleInstall = () => {
void install()
void applyUpdates()
}
return (
@@ -92,7 +73,7 @@ export function UpdatesOverlay() {
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
{phase === 'applying' && <ApplyingView apply={apply} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
@@ -109,9 +90,8 @@ export function UpdatesOverlay() {
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void check()}
onRetryCheck={() => void checkUpdates()}
status={status}
target={target}
/>
)}
</DialogContent>
@@ -126,8 +106,7 @@ function IdleView({
onInstall,
onLater,
onRetryCheck,
status,
target
status
}: {
behind: number
checking: boolean
@@ -136,7 +115,6 @@ function IdleView({
onLater: () => void
onRetryCheck: () => void
status: DesktopUpdateStatus | null
target: UpdateTarget
}) {
const { t } = useI18n()
const u = t.updates
@@ -189,7 +167,7 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
body={u.latestBody}
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title={u.allSetTitle}
/>
@@ -200,20 +178,14 @@ function IdleView({
const shownItems = totalItems(groups)
const remaining = Math.max(0, behind - shownItems)
// Name what's being updated. In remote mode the overlay acts on the connected
// backend, not the local client — say so. When there are no commit rows to
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
// instead of generic filler.
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.availableBody}
</DialogDescription>
</div>
@@ -309,11 +281,10 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
)
}
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const { t } = useI18n()
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
@@ -327,7 +298,7 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.applyingBody}
</DialogDescription>
</div>
@@ -494,9 +494,11 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
)
}
@@ -77,7 +77,6 @@ import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
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 { extractPreviewTargets } from '@/lib/preview-targets'
@@ -920,7 +919,7 @@ const SystemMessage: FC = () => {
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
</MessagePrimitive.Root>
)
}
@@ -931,7 +930,7 @@ const SystemMessage: FC = () => {
data-role="system"
data-slot="aui_system-message-root"
>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
<span className="whitespace-pre-wrap">{text}</span>
</MessagePrimitive.Root>
)
}
-11
View File
@@ -7,7 +7,6 @@ import type {
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
BackendUpdateCheckResponse,
ConfigSchemaResponse,
CronJob,
CronJobCreatePayload,
@@ -54,7 +53,6 @@ export type {
AnalyticsSkillEntry,
AnalyticsSkillsSummary,
AnalyticsTotals,
BackendUpdateCheckResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
AuxiliaryModelsResponse,
@@ -688,15 +686,6 @@ export function updateHermes(): Promise<ActionResponse> {
})
}
/** Query the connected backend's own update state. In remote mode this is the
* authoritative source for the backend's behind-count + "what's changed",
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
+2 -20
View File
@@ -292,8 +292,7 @@ export const en: Translations = {
technical: 'Technical',
technicalDesc: 'Include raw tool args/results and low-level details.',
themeTitle: 'Theme',
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@@ -1081,7 +1080,6 @@ export const en: Translations = {
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
@@ -1239,13 +1237,9 @@ export const en: Translations = {
unsupportedMessage: 'This version of Hermes cant update itself from inside the app.',
connectionRetry: 'Check your connection and try again.',
latestBody: 'Youre running the latest version.',
latestBodyBackend: 'The backend is running the latest version.',
allSetTitle: 'Youre all set',
availableTitle: 'New update available',
availableBody: 'A new version of Hermes is ready to install.',
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes arent available for this install type.',
updateNow: 'Update now',
maybeLater: 'Maybe later',
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
@@ -1256,19 +1250,10 @@ export const en: Translations = {
copied: 'Copied',
done: 'Done',
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when its done.',
applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when its back.',
applyingClose: 'Hermes will close to apply the update.',
errorTitle: 'Update didnt finish',
errorBody: 'No worries — nothing was lost. You can try again now.',
notNow: 'Not now',
applyStatus: {
preparing: 'Updating backend…',
pulling: 'Backend updating…',
restarting: 'Backend restarting to load the update…',
notAvailable: 'Update not available for this backend.',
failed: 'Backend update failed.',
noReturn: 'Backend didnt come back online. The update may not have completed — check the backend host.'
}
notNow: 'Not now'
},
install: {
@@ -1454,9 +1439,6 @@ export const en: Translations = {
updateInProgress: 'Update in progress',
commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `Backend v${version}`,
clientLabel: version => `client v${version}`,
backendLabel: version => `backend v${version}`,
commit: sha => `commit ${sha}`,
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
+2 -20
View File
@@ -215,8 +215,7 @@ export const ja = defineLocale({
technical: 'テクニカル',
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
themeTitle: 'テーマ',
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
themeProfileNote: profile => `${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@@ -1224,7 +1223,6 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
@@ -1380,13 +1378,9 @@ export const ja = defineLocale({
unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。',
connectionRetry: '接続を確認してもう一度試してください。',
latestBody: '最新バージョンを実行しています。',
latestBodyBackend: 'バックエンドは最新バージョンを実行しています。',
allSetTitle: '準備完了',
availableTitle: '新しい更新が利用可能',
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
availableTitleBackend: 'バックエンドの更新があります',
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
updateNow: '今すぐ更新',
maybeLater: '後で',
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
@@ -1398,19 +1392,10 @@ export const ja = defineLocale({
copied: 'コピーしました',
done: '完了',
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に Hermes を再度開きます。',
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
applyingClose: 'Hermes は更新を適用するために閉じます。',
errorTitle: '更新が完了しませんでした',
errorBody: 'ご安心ください。何も失われていません。今すぐ再試行できます。',
notNow: '今は後で',
applyStatus: {
preparing: 'バックエンドを更新しています…',
pulling: 'バックエンドを更新中…',
restarting: 'バックエンドが更新を読み込むため再起動しています…',
notAvailable: 'このバックエンドでは更新を利用できません。',
failed: 'バックエンドの更新に失敗しました。',
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
}
notNow: '今は後で'
},
install: {
@@ -1597,9 +1582,6 @@ export const ja = defineLocale({
updateInProgress: '更新中',
commitsBehind: (count, branch) => `${branch} より ${count} コミット遅れています`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `バックエンド v${version}`,
clientLabel: version => `クライアント v${version}`,
backendLabel: version => `バックエンド v${version}`,
commit: sha => `コミット ${sha}`,
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
-18
View File
@@ -219,7 +219,6 @@ export interface Translations {
technicalDesc: string
themeTitle: string
themeDesc: string
themeProfileNote: (profile: string) => string
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@@ -838,7 +837,6 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
@@ -939,13 +937,9 @@ export interface Translations {
unsupportedMessage: string
connectionRetry: string
latestBody: string
latestBodyBackend: string
allSetTitle: string
availableTitle: string
availableBody: string
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
updateNow: string
maybeLater: string
moreChanges: (count: number) => string
@@ -956,19 +950,10 @@ export interface Translations {
copied: string
done: string
applyingBody: string
applyingBodyBackend: string
applyingClose: string
errorTitle: string
errorBody: string
notNow: string
applyStatus: {
preparing: string
pulling: string
restarting: string
notAvailable: string
failed: string
noReturn: string
}
}
install: {
@@ -1126,9 +1111,6 @@ export interface Translations {
updateInProgress: string
commitsBehind: (count: number, branch: string) => string
desktopVersion: (version: string) => string
backendVersion: (version: string) => string
clientLabel: (version: string) => string
backendLabel: (version: string) => string
commit: (sha: string) => string
branch: (branch: string) => string
closeCommandCenter: string
+2 -20
View File
@@ -209,8 +209,7 @@ export const zhHant = defineLocale({
technical: '技術',
technicalDesc: '包含原始工具參數、結果與底層細節。',
themeTitle: '主題',
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@@ -1190,7 +1189,6 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
@@ -1346,13 +1344,9 @@ export const zhHant = defineLocale({
unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。',
connectionRetry: '請檢查網路連線後重試。',
latestBody: '您正在執行最新版本。',
latestBodyBackend: '後端正在執行最新版本。',
allSetTitle: '已是最新版本',
availableTitle: '有可用更新',
availableBody: '新版 Hermes 已可安裝。',
availableTitleBackend: '後端有可用更新',
availableBodyBackend: '已連接的 Hermes 後端有新版本可安裝。',
availableBodyNoChangelog: '已有新版本可用。此安裝方式無法顯示更新日誌。',
updateNow: '立即更新',
maybeLater: '稍後再說',
moreChanges: count => `另有 ${count} 項變更。`,
@@ -1363,19 +1357,10 @@ export const zhHant = defineLocale({
copied: '已複製',
done: '完成',
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後重新開啟 Hermes。',
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
applyingClose: 'Hermes 將關閉以套用更新。',
errorTitle: '更新未完成',
errorBody: '沒有資料遺失。您可以現在重試。',
notNow: '暫不',
applyStatus: {
preparing: '正在更新後端…',
pulling: '後端更新中…',
restarting: '後端正在重新啟動以載入更新…',
notAvailable: '此後端無法更新。',
failed: '後端更新失敗。',
noReturn: '後端未恢復連線。更新可能未完成——請檢查後端主機。'
}
notNow: '暫不'
},
install: {
@@ -1558,9 +1543,6 @@ export const zhHant = defineLocale({
updateInProgress: '更新中',
commitsBehind: (count, branch) => `落後 ${branch} ${count} 個提交`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `後端 v${version}`,
clientLabel: version => `用戶端 v${version}`,
backendLabel: version => `後端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
+2 -20
View File
@@ -287,8 +287,7 @@ export const zh: Translations = {
technical: '技术',
technicalDesc: '包含原始工具参数/结果及底层细节。',
themeTitle: '主题',
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@@ -1268,7 +1267,6 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
@@ -1426,13 +1424,9 @@ export const zh: Translations = {
unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
connectionRetry: '请检查网络连接后重试。',
latestBody: '你正在运行最新版本。',
latestBodyBackend: '后端正在运行最新版本。',
allSetTitle: '已是最新',
availableTitle: '有可用更新',
availableBody: '新版 Hermes 已可安装。',
availableTitleBackend: '后端有可用更新',
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
updateNow: '立即更新',
maybeLater: '稍后再说',
moreChanges: count => `另有 ${count} 项更改。`,
@@ -1443,19 +1437,10 @@ export const zh: Translations = {
copied: '已复制',
done: '完成',
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。',
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
applyingClose: 'Hermes 将关闭以应用更新。',
errorTitle: '更新未完成',
errorBody: '没有数据丢失。你可以现在重试。',
notNow: '暂不',
applyStatus: {
preparing: '正在更新后端…',
pulling: '后端更新中…',
restarting: '后端正在重启以加载更新…',
notAvailable: '此后端无法更新。',
failed: '后端更新失败。',
noReturn: '后端未恢复在线。更新可能未完成——请检查后端主机。'
}
notNow: '暂不'
},
install: {
@@ -1635,9 +1620,6 @@ export const zh: Translations = {
updateInProgress: '正在更新',
commitsBehind: (count, branch) => `落后 ${branch} ${count} 个提交`,
desktopVersion: version => `Hermes Desktop v${version}`,
backendVersion: version => `后端 v${version}`,
clientLabel: version => `客户端 v${version}`,
backendLabel: version => `后端 v${version}`,
commit: sha => `提交 ${sha}`,
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
@@ -165,31 +165,4 @@ describe('external link helpers', () => {
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
)
})
it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => {
installDesktopBridge()
render(
<LinkifiedText
explicitOnly
pretty={false}
text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'}
/>
)
const links = screen.getAllByRole('link')
expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def'])
// Bare filename-shaped tokens stay as plain text, not links.
expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy()
expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false)
})
it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => {
installDesktopBridge()
render(<LinkifiedText pretty={false} text="open agent.log please" />)
const link = screen.getByRole('link', { name: 'agent.log' })
expect(link.getAttribute('href')).toBe('https://agent.log')
})
})
+2 -9
View File
@@ -12,12 +12,6 @@ const titleSubs = new Map<string, Set<(value: string) => void>>()
const URL_RE =
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
// otherwise mistake for domains and linkify.
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
@@ -267,14 +261,13 @@ interface LinkifiedTextProps {
className?: string
text: string
pretty?: boolean
explicitOnly?: boolean
}
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
for (const match of text.matchAll(URL_RE)) {
const raw = match[0]
const url = normalizeExternalUrl(raw)
const index = match.index ?? 0
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { gatewayEventRequiresSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
})
it('attributes unscoped foreground turn events to the active chat', () => {
// These must NOT be dropped when unscoped — they are the focused turn's own
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
})
it('allows global events to remain unscoped', () => {
expect(gatewayEventRequiresSessionId('gateway.ready')).toBe(false)
expect(gatewayEventRequiresSessionId('preview.restart.progress')).toBe(false)
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
})
})
-16
View File
@@ -11,22 +11,6 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
*
* Only `subagent.*` qualifies: it describes background/async work that must
* never attach to whichever chat happens to be focused. Every other scoped
* event message/reasoning/thinking/tool/status/prompt is, when unscoped,
* the active turn's own output. The gateway always stamps a *background*
* session's events with that session's id, so a missing id can only mean "the
* focused turn". #42178 dropped those too, which silently swallowed the live
* answer; it then reappeared only after a transcript refetch (manual refresh).
*/
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
return eventType?.startsWith('subagent.') ?? false
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false
-64
View File
@@ -34,76 +34,12 @@ const SOURCE_ALIASES: Record<string, string[]> = {
whatsapp: ['wa']
}
// Sources that run on the local machine rather than an external messaging
// platform. A handoff *from* one of these isn't a platform origin worth a badge.
// Exported so the recents fetch can keep these in the main list while the
// messaging fetch excludes them.
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
// External messaging platforms that each get their own self-managed sidebar
// section (fetched separately from local recents). Mirrors the gateway platform
// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
export const MESSAGING_SESSION_SOURCE_IDS = [
'telegram',
'discord',
'slack',
'mattermost',
'matrix',
'signal',
'whatsapp',
'bluebubbles',
'homeassistant',
'email',
'sms',
'webhook',
'api_server',
'weixin',
'wecom',
'qqbot',
'yuanbao',
'dingtalk',
'feishu'
]
const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
/** True when a source id is an external messaging platform (gets its own
* sidebar section) rather than a local/CLI/desktop session. */
export function isMessagingSource(source: null | string | undefined): boolean {
const id = normalizeSessionSource(source)
return id != null && MESSAGING_SOURCE_IDS.has(id)
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
/**
* Resolve the origin messaging platform for a handed-off session. Returns the
* normalized platform id (e.g. 'telegram') when the session completed a handoff
* from a real messaging platform, otherwise null. After a handoff the live
* source is local, so this is what drives the row's origin-platform badge.
*/
export function handoffOriginSource(
handoffState: null | string | undefined,
handoffPlatform: null | string | undefined
): string | null {
if (handoffState !== 'completed') {
return null
}
const id = normalizeSessionSource(handoffPlatform)
if (!id || LOCAL_SOURCE_IDS.has(id)) {
return null
}
return id
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveUpdateCopy } from './update-copy'
const copy = {
availableTitle: 'New update available',
availableBody: 'A new version of Hermes is ready to install.',
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes arent available for this install type.'
}
describe('resolveUpdateCopy', () => {
it('client target with commits: client title + client body', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, copy })
expect(r.title).toBe('New update available')
expect(r.body).toBe('A new version of Hermes is ready to install.')
})
it('backend target with commits: names the backend in title and body', () => {
const r = resolveUpdateCopy({ target: 'backend', shownItems: 5, copy })
expect(r.title).toBe('Backend update available')
expect(r.body).toContain('backend')
})
it('no changelog (pip/non-git backend): degrades honestly, still names backend target in title', () => {
const r = resolveUpdateCopy({ target: 'backend', shownItems: 0, copy })
expect(r.title).toBe('Backend update available')
// Body must NOT pretend there are notes — it states they're unavailable.
expect(r.body).toBe(copy.availableBodyNoChangelog)
})
it('no changelog on client: same honest degrade', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, copy })
expect(r.title).toBe('New update available')
expect(r.body).toBe(copy.availableBodyNoChangelog)
})
})
-44
View File
@@ -1,44 +0,0 @@
/**
* Pure copy-selection for the updates overlay's "available" state.
*
* Names the update target (client vs the connected backend in remote mode) and
* degrades honestly when there's no commit changelog to show (e.g. a pip /
* non-git backend where `git log` yields nothing) instead of generic filler.
*
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
*/
export type UpdateTarget = 'client' | 'backend'
export interface UpdateCopyStrings {
availableTitle: string
availableBody: string
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
}
export interface ResolveUpdateCopyInput {
target: UpdateTarget
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
shownItems: number
copy: UpdateCopyStrings
}
export interface UpdateCopyResult {
title: string
body: string
}
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
const body =
shownItems === 0
? copy.availableBodyNoChangelog
: target === 'backend'
? copy.availableBodyBackend
: copy.availableBody
return { title, body }
}
+2 -68
View File
@@ -1,16 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import {
$attentionSessionIds,
$workingSessionIds,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setSessionAttention,
setSessionWorking
} from './session'
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
@@ -137,61 +129,3 @@ describe('mergeSessionPage', () => {
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
})
})
describe('getRecentlySettledSessionIds', () => {
afterEach(() => {
vi.useRealTimers()
$workingSessionIds.set([])
// Drain anything left in the grace map so tests stay isolated.
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
void id
}
})
it('keeps a session for the grace window after its turn settles, then drops it', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// A turn starts then ends: the working→idle transition grants grace.
setSessionWorking('s1', true)
setSessionWorking('s1', false)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Still inside the window.
vi.setSystemTime(29_000)
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
// Past the window: the entry is pruned on read.
vi.setSystemTime(31_000)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('does not grant grace when the session was never working (idle re-asserts)', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
// updateSessionState re-asserts `false` for idle sessions on every tick;
// these must not pin an idle chat into the keep-set indefinitely.
setSessionWorking('idle', false)
setSessionWorking('idle', false)
expect(getRecentlySettledSessionIds()).toEqual([])
})
it('clears the grace timer when the session goes busy again', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
$workingSessionIds.set([])
setSessionWorking('s2', true)
setSessionWorking('s2', false)
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
// A new turn for the same session is "working" again — drop it from the
// settled set so it's tracked as working, not recently-finished.
setSessionWorking('s2', true)
expect(getRecentlySettledSessionIds()).toEqual([])
})
})
-70
View File
@@ -85,20 +85,6 @@ export const $cronSessions = atom<SessionInfo[]>([])
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
// share one source of truth without a circular import.
export const CRON_SECTION_LIMIT = 50
// Messaging-platform sessions (telegram/discord/...) are fetched as their own
// slice — separate from local recents — so each platform renders a
// self-managed sidebar section and never interleaves with (or buries) local
// chats in the recents page. One combined fetch seeds every platform; a
// platform that exceeds this cap gets its own per-platform "load more".
export const $messagingSessions = atom<SessionInfo[]>([])
export const MESSAGING_SECTION_LIMIT = 100
// Exact per-platform conversation totals, keyed by source id. Empty until a
// per-platform "load more" fetch resolves it (the combined seed fetch only
// knows the aggregate), so sections fall back to their loaded count.
export const $messagingPlatformTotals = atom<Record<string, number>>({})
// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
// one platform may have more rows on disk than were loaded.
export const $messagingTruncated = atom<boolean>(false)
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
@@ -143,10 +129,6 @@ export const setGatewayState = (next: Updater<string>) => updateAtom($gatewaySta
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
updateAtom($messagingPlatformTotals, next)
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
@@ -220,47 +202,6 @@ function clearSessionWatchdog(sessionId: string) {
}
}
// A session's "working" flag clears the instant its turn ends, but the
// cross-profile aggregator (listSessions with min_messages=1) only sees the
// just-persisted first turn a beat later. The active chat is shielded from that
// race by sessionsToKeep(), but a brand-new session that finished *while you
// were viewing a different chat* is, at the next refresh, neither working,
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
// then click another session before the first reply lands.)
//
// To bridge that window we keep a session in the merge keep-set for a short
// grace period after its turn settles, giving the aggregator time to catch up.
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
// session (mergeSessionPage only revives rows still present in the in-memory
// list, which optimistic delete/archive already drops).
const SESSION_SETTLE_GRACE_MS = 30 * 1000
const settledSessionExpiry = new Map<string, number>()
function markSessionSettled(sessionId: string) {
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
}
function clearSessionSettled(sessionId: string) {
settledSessionExpiry.delete(sessionId)
}
/** Stored ids of sessions whose turn ended within the grace window. Prunes
* expired entries as it reads, so it stays bounded without a timer. */
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
const live: string[] = []
for (const [id, expiry] of settledSessionExpiry) {
if (expiry > now) {
live.push(id)
} else {
settledSessionExpiry.delete(id)
}
}
return live
}
/** Call when a streaming event for a session lands. Refreshes the watchdog
* so the session keeps its "working" status as long as data keeps coming. */
export function noteSessionActivity(sessionId: string | null | undefined) {
@@ -302,24 +243,13 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
return
}
const wasWorking = $workingSessionIds.get().includes(sessionId)
toggleMembership(setWorkingSessionIds, sessionId, working)
// Bookend the watchdog: arm on enter, disarm on leave. A later
// noteSessionActivity() from a streaming event refreshes the timer.
if (working) {
clearSessionSettled(sessionId)
armSessionWatchdog(sessionId)
} else {
clearSessionWatchdog(sessionId)
// Only grant grace on a real working→idle transition (updateSessionState
// re-asserts `false` on every state tick, which must not keep extending the
// window). This keeps the just-finished session visible long enough for the
// aggregator to return its now-persisted row.
if (wasWorking) {
markSessionSettled(sessionId)
}
}
}
+2 -124
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
@@ -23,18 +23,7 @@ vi.mock('@/store/notifications', () => ({
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
}))
const checkHermesUpdateSpy = vi.fn()
const updateHermesSpy = vi.fn()
const getActionStatusSpy = vi.fn()
vi.mock('@/hermes', () => ({
checkHermesUpdate: (...args: unknown[]) => checkHermesUpdateSpy(...args),
updateHermes: (...args: unknown[]) => updateHermesSpy(...args),
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
}))
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
const { setConnection } = await import('./session')
const { maybeNotifyUpdateAvailable } = await import('./updates')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
supported: true,
@@ -86,114 +75,3 @@ describe('maybeNotifyUpdateAvailable', () => {
expect(notifySpy).not.toHaveBeenCalled()
})
})
describe('checkBackendUpdates', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
checkHermesUpdateSpy.mockReset()
$backendUpdateStatus.set(null)
vi.useRealTimers()
})
const setRemote = (on: boolean) =>
setConnection({
baseUrl: 'http://box:9119',
isFullscreen: false,
mode: on ? 'remote' : 'local',
nativeOverlayWidth: 0,
token: 't',
wsUrl: 'ws://box:9119',
logs: [],
windowButtonPosition: null
})
it('maps the backend /update/check onto the backend status, including commits', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'git',
current_version: '0.16.0',
behind: 2,
update_available: true,
can_apply: true,
update_command: 'hermes update',
message: null,
commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }]
})
const result = await checkBackendUpdates()
expect(checkHermesUpdateSpy).toHaveBeenCalled()
expect(result?.behind).toBe(2)
expect(result?.commits?.[0]?.sha).toBe('abc1234')
expect(result?.supported).toBe(true)
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
})
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
setRemote(true)
checkHermesUpdateSpy.mockResolvedValue({
install_method: 'docker',
current_version: '0.16.0',
behind: null,
update_available: false,
can_apply: false,
update_command: 'docker pull ...',
message: 'Docker images are immutable.'
})
const result = await checkBackendUpdates()
expect(result?.supported).toBe(false)
expect(result?.message).toBe('Docker images are immutable.')
})
it('is a no-op in local mode (backend check only runs when remote)', async () => {
setRemote(false)
await checkBackendUpdates()
expect(checkHermesUpdateSpy).not.toHaveBeenCalled()
})
})
describe('applyBackendUpdate recovery', () => {
beforeEach(() => {
storage.clear()
checkHermesUpdateSpy.mockReset()
updateHermesSpy.mockReset()
getActionStatusSpy.mockReset()
$backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] })
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null })
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(5000)
const result = await promise
expect(result.ok).toBe(true)
expect($backendUpdateApply.get().stage).toBe('idle')
expect($backendUpdateApply.get().applying).toBe(false)
})
it('surfaces an error when the backend never comes back after the restart', async () => {
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
checkHermesUpdateSpy.mockRejectedValue(new Error('ECONNREFUSED'))
const promise = applyBackendUpdate()
await vi.advanceTimersByTimeAsync(70000)
const result = await promise
expect(result.ok).toBe(false)
expect($backendUpdateApply.get().stage).toBe('error')
})
})
+16 -195
View File
@@ -13,12 +13,9 @@ import type {
DesktopUpdateStatus,
DesktopVersionInfo
} from '@/global'
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
import { translateNow } from '@/i18n'
import { persistString, storedString } from '@/lib/storage'
import { dismissNotification, notify } from '@/store/notifications'
import { $connection } from '@/store/session'
import type { BackendUpdateCheckResponse } from '@/types/hermes'
export interface UpdateApplyState {
applying: boolean
@@ -48,24 +45,8 @@ export const $updateChecking = atom<boolean>(false)
export const $updateOverlayOpen = atom<boolean>(false)
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
// Client and backend are independently updatable; each keeps its own state.
export const $backendUpdateStatus = atom<DesktopUpdateStatus | null>(null)
export const $backendUpdateApply = atom<UpdateApplyState>(IDLE)
export const $backendUpdateChecking = atom<boolean>(false)
export type UpdateTarget = 'client' | 'backend'
export const $updateOverlayTarget = atom<UpdateTarget>('client')
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
export const openUpdateOverlayFor = (target: UpdateTarget) => {
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
}
export const resetUpdateApplyState = () => {
$updateApply.set(IDLE)
$backendUpdateApply.set(IDLE)
}
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
const UPDATE_TOAST_ID = 'desktop-update-available'
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
@@ -88,8 +69,7 @@ function isUpdateToastSnoozed(): boolean {
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const REQUIRED_BACKEND_CONTRACT = 1
const SKEW_TOAST_ID = 'backend-contract-skew'
/**
@@ -106,7 +86,7 @@ export function reportBackendContract(contract: number | undefined): void {
}
notify({
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
@@ -157,8 +137,13 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
})
}
/**
* Opens the updates dialog and kicks off a fresh check so the user always
* sees current state, even if a stale status is cached from earlier.
*/
export function openUpdatesWindow(): void {
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
$updateOverlayOpen.set(true)
void checkUpdates()
}
/** Re-read the running app's version from the Electron main process and
@@ -189,52 +174,6 @@ export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null
}
}
function isRemoteMode(): boolean {
return $connection.get()?.mode === 'remote'
}
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
const behind = res.behind ?? 0
return {
supported: res.can_apply,
message: res.message ?? undefined,
behind: behind > 0 ? behind : 0,
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
commits: res.commits,
fetchedAt: Date.now()
}
}
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
if (!isRemoteMode() || $backendUpdateChecking.get()) {
return $backendUpdateStatus.get()
}
$backendUpdateChecking.set(true)
try {
const status = mapBackendCheck(await checkHermesUpdate(true))
$backendUpdateStatus.set(status)
maybeNotifyUpdateAvailable(status)
return status
} catch (error) {
const fallback: DesktopUpdateStatus = {
supported: $backendUpdateStatus.get()?.supported ?? true,
error: 'check-failed',
message: error instanceof Error ? error.message : String(error),
fetchedAt: Date.now()
}
$backendUpdateStatus.set(fallback)
return fallback
} finally {
$backendUpdateChecking.set(false)
}
}
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const bridge = window.hermesDesktop?.updates
@@ -248,6 +187,9 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
const status = await bridge.check()
$updateStatus.set(status)
maybeNotifyUpdateAvailable(status)
// The update check pulls the latest hermes_cli + bundled package metadata
// into place. Re-read the running version so About reflects the now-fresh
// checkout rather than the one captured at process start.
void refreshDesktopVersion()
return status
@@ -305,107 +247,6 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
}
}
const BACKEND_RETURN_POLL_MS = 1500
const BACKEND_RETURN_MAX_ATTEMPTS = 40
async function waitForBackendReturn(): Promise<boolean> {
for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) {
await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS))
try {
await checkHermesUpdate()
return true
} catch {
continue
}
}
return false
}
function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
if (returned) {
$backendUpdateApply.set(IDLE)
setUpdateOverlayOpen(false)
void checkBackendUpdates()
return { ok: true, message: 'Backend update applied.' }
}
$backendUpdateApply.set({
...$backendUpdateApply.get(),
applying: false,
stage: 'error',
error: 'apply-failed',
message: translateNow('updates.applyStatus.noReturn')
})
return { ok: false, error: 'apply-failed', message: 'Backend did not come back online.' }
}
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
dismissNotification(UPDATE_TOAST_ID)
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: translateNow('updates.applyStatus.preparing') })
try {
const started = await updateHermes()
if (!started.ok) {
const message = (started as { message?: string }).message || translateNow('updates.applyStatus.notAvailable')
const command = (started as { update_command?: string }).update_command || 'hermes update'
$backendUpdateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command })
return { ok: false, error: 'manual', manual: true, message, command }
}
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: translateNow('updates.applyStatus.pulling') })
let last: Awaited<ReturnType<typeof getActionStatus>> | null = null
for (let attempt = 0; attempt < 30; attempt += 1) {
await new Promise(resolve => globalThis.setTimeout(resolve, 1500))
try {
last = await getActionStatus(started.name, 200)
} catch {
// The dashboard restarts mid-update, dropping this connection — expected, not a failure.
$backendUpdateApply.set({
...$backendUpdateApply.get(),
applying: true,
stage: 'restart',
message: translateNow('updates.applyStatus.restarting')
})
return finishBackendApply(await waitForBackendReturn())
}
if (last && !last.running) {
break
}
}
const ok = !!last && (last.exit_code ?? 1) === 0
if (ok) {
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') })
return finishBackendApply(await waitForBackendReturn())
}
$backendUpdateApply.set({
...$backendUpdateApply.get(),
applying: false,
stage: 'error',
error: 'apply-failed',
message: translateNow('updates.applyStatus.failed')
})
return { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
return { ok: false, error: 'apply-failed', message }
}
}
function ingestProgress(payload: DesktopUpdateProgress): void {
const current = $updateApply.get()
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
@@ -426,8 +267,6 @@ function ingestProgress(payload: DesktopUpdateProgress): void {
let pollerStarted = false
let backgroundTimer: ReturnType<typeof setInterval> | null = null
let lastFocusAt = 0
let connectionUnsub: (() => void) | null = null
let lastConnectionMode: string | undefined
/** Wire up background polling + progress streaming. Idempotent. */
export function startUpdatePoller(): void {
@@ -443,28 +282,11 @@ export function startUpdatePoller(): void {
pollerStarted = true
void checkUpdates()
void checkBackendUpdates()
void refreshDesktopVersion()
bridge.onProgress(ingestProgress)
// The poller starts at mount, before the gateway connects — so the first
// backend check above sees mode≠remote and no-ops. Re-check once the
// connection resolves to remote.
connectionUnsub = $connection.subscribe(conn => {
if (conn?.mode === lastConnectionMode) {
return
}
lastConnectionMode = conn?.mode
if (conn?.mode === 'remote') {
void checkBackendUpdates()
}
})
window.addEventListener('focus', onFocus)
backgroundTimer = setInterval(() => {
void checkUpdates()
void checkBackendUpdates()
}, 30 * 60 * 1000)
backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000)
}
export function stopUpdatePoller(): void {
@@ -473,9 +295,6 @@ export function stopUpdatePoller(): void {
backgroundTimer = null
}
connectionUnsub?.()
connectionUnsub = null
lastConnectionMode = undefined
window.removeEventListener('focus', onFocus)
pollerStarted = false
}
@@ -489,6 +308,8 @@ function onFocus() {
lastFocusAt = now
void checkUpdates()
void checkBackendUpdates()
// Cheap and safe to re-read on every (throttled) focus: the user may have
// updated Hermes from another window/CLI between focuses, and About should
// catch up without forcing a restart.
void refreshDesktopVersion()
}
+10 -68
View File
@@ -9,28 +9,15 @@
* The two are persisted independently. Shift+X toggles light/dark.
*/
import { useStore } from '@nanostores/react'
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
// for any profile without its own assignment, so single-profile users and old
// installs are unaffected.
const SKIN_KEY = 'hermes-desktop-theme-v2'
const MODE_KEY = 'hermes-desktop-mode-v1'
// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A
// profile inherits the global default until it's given its own appearance.
const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1'
const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1'
// Last active profile, recorded so the boot-time paint can pick that profile's
// theme before the gateway reports which profile actually launched.
const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1'
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
export type ThemeMode = 'light' | 'dark' | 'system'
@@ -40,36 +27,9 @@ const INJECTED_FONT_URLS = new Set<string>()
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
const normalizeSkin = (name: string | null): string =>
const normalizeSkin = (name: string | null | undefined): string =>
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
const normalizeMode = (value: string | null): ThemeMode =>
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
// ─── Per-profile appearance persistence ─────────────────────────────────────
// Skin and mode are each stored per profile. "default" isn't a real profile —
// it *is* the legacy global slot, so it reads/writes the global directly. Named
// profiles get their own entry and fall back to that global until assigned, so
// unassigned profiles and pre-per-profile installs stay on the global value.
const profilePref = <T extends string>(record: string, legacy: string, normalize: (v: string | null) => T) => ({
resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)),
assign: (profile: string, value: T): void => {
if (profile === 'default') {
persistString(legacy, value)
} else {
persistStringRecord(record, { ...storedStringRecord(record), [profile]: value })
}
}
})
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)
// Last active profile — lets the boot paint pick its appearance before the
// gateway reports which profile actually launched.
const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY))
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
// ─── Color math (for synthesised light variants of dark-only skins) ────────
function hexToRgb(hex: string): [number, number, number] | null {
@@ -271,13 +231,12 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
}
}
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
// active profile's appearance so a non-default profile relaunch paints its own
// skin + light/dark mode.
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
if (typeof window !== 'undefined') {
const profile = readBootProfileKey()
const resolved = resolveMode(modePref.resolve(profile))
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
const resolved = resolveMode(mode)
applyTheme(deriveTheme(skin, resolved), resolved)
}
// ─── Context ────────────────────────────────────────────────────────────────
@@ -305,46 +264,29 @@ const ThemeContext = createContext<ThemeContextValue>({
})
export function ThemeProvider({ children }: { children: ReactNode }) {
// Skin + mode are assigned per profile; the active profile drives which
// appearance shows. Single-profile users only ever see "default", so their
// behavior is unchanged.
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const [themeName, setThemeNameState] = useState(() =>
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
)
const [mode, setModeState] = useState<ThemeMode>(() =>
typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey())
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
)
// Follow profile switches: paint the profile's assigned skin + mode and
// remember it for the next boot's first paint.
useEffect(() => {
rememberActiveProfileKey(profileKey)
setThemeNameState(skinPref.resolve(profileKey))
setModeState(modePref.resolve(profileKey))
}, [profileKey])
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
// stay stable across profile switches).
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
const setTheme = useCallback((name: string) => {
const next = normalizeSkin(name)
setThemeNameState(next)
skinPref.assign(liveProfile(), next)
window.localStorage.setItem(SKIN_KEY, next)
}, [])
const setMode = useCallback((next: ThemeMode) => {
setModeState(next)
modePref.assign(liveProfile(), next)
window.localStorage.setItem(MODE_KEY, next)
}, [])
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
@@ -1,41 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { modePref, skinPref } from './context'
import { DEFAULT_SKIN_NAME } from './presets'
// Skin and mode share one per-profile contract, so assert it once over both.
interface Pref {
resolve: (profile: string) => string
assign: (profile: string, value: string) => void
}
const cases = [
{ name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
]
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
beforeEach(() => window.localStorage.clear())
it('falls back to the default when unassigned', () => {
expect(pref.resolve('default')).toBe(fallback)
expect(pref.resolve('work')).toBe(fallback)
})
it('keeps each profile on its own value', () => {
pref.assign('work', a)
pref.assign('default', b)
expect(pref.resolve('work')).toBe(a)
expect(pref.resolve('default')).toBe(b)
})
it('lets unassigned profiles inherit the default profile as the global fallback', () => {
pref.assign('default', a)
expect(pref.resolve('never-themed')).toBe(a)
})
it('normalizes an unknown stored value back to the default', () => {
pref.assign('work', junk)
expect(pref.resolve('work')).toBe(fallback)
})
})
-29
View File
@@ -297,14 +297,6 @@ export interface SessionInfo {
started_at: number
title: null | string
tool_call_count: number
/** Origin platform when this session was handed off from a messaging
* platform (e.g. a Telegram thread continued in the desktop app). The live
* {@link source} becomes local (tui/desktop) after a handoff, so the origin
* is preserved here to surface the platform badge on the row. */
handoff_platform?: null | string
/** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */
handoff_state?: null | string
handoff_error?: null | string
/** Owning profile name, set by the cross-profile aggregator
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
* which the UI treats as the default profile. */
@@ -604,27 +596,6 @@ export interface ActionStatusResponse {
running: boolean
}
export interface BackendUpdateCommit {
sha: string
summary: string
author: string
at: number
}
/** Shape of `GET /api/hermes/update/check` the backend's own update state.
* Used by the desktop's remote update overlay so the backend version (not the
* Electron client clone) drives "what's changed + Install" in remote mode. */
export interface BackendUpdateCheckResponse {
install_method: string
current_version: string
behind: number | null
update_available: boolean
can_apply: boolean
update_command: string | null
message: string | null
commits?: BackendUpdateCommit[]
}
export interface AuxiliaryTaskAssignment {
base_url: string
model: string
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2023",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
-9
View File
@@ -528,15 +528,6 @@ session_reset:
idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
# Maximum number of simultaneously active chat sessions across CLI, TUI,
# dashboard chat, and messaging gateway. Set to null, 0, or omit to allow
# unlimited concurrent sessions. When the limit is reached, new sessions get a
# clean error while existing active sessions keep their normal behavior. This
# top-level key takes precedence over gateway.max_concurrent_sessions. The cap
# is a best-effort single-host/profile runtime guard; Hermes fails open if the
# local runtime lease registry cannot be read or locked.
max_concurrent_sessions: null
# When true, group/channel chats use one session per participant when the platform
# provides a user ID. This is the secure default and prevents users in the same
# room from sharing context, interrupts, and token costs. Set false only if you
+902 -358
View File
File diff suppressed because it is too large Load Diff
-426
View File
@@ -1,426 +0,0 @@
"""User-authorization methods for ``GatewayRunner``.
Extracted from ``gateway/run.py`` as part of the god-file decomposition campaign
(``~/.hermes/plans/god-file-decomposition.md``, Phase 3 mechanical mixin lifts).
This mixin holds the inbound-message authorization cluster: whether a user/chat
is allowed to talk to the agent, the per-adapter DM policy, and the
unauthorized-DM behavior.
Behavior-neutral: every method is lifted verbatim from ``GatewayRunner``.
``self.*`` calls resolve unchanged via the MRO. Neutral dependencies import at
module top; the module-level ``logger`` is imported lazily inside the one method
that uses it (``from gateway.run import logger`` resolves at call time, when
``gateway.run`` is fully loaded) so this module never imports ``gateway.run`` at
import time -> no import cycle. The lazy import preserves the exact logger name
(``"gateway.run"``) so log records are unchanged.
"""
from __future__ import annotations
import os
from typing import Optional
from gateway.config import Platform
from gateway.session import SessionSource
from gateway.whatsapp_identity import (
expand_whatsapp_aliases as _expand_whatsapp_auth_aliases,
normalize_whatsapp_identifier as _normalize_whatsapp_identifier,
)
class GatewayAuthorizationMixin:
"""User/chat authorization methods for ``GatewayRunner``."""
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
"""Whether the adapter for *platform* gates access at intake itself.
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
"""
if not platform:
return False
# Some test helpers build a bare GatewayRunner via object.__new__ and
# never set ``adapters``; treat a missing/empty map as "no adapter"
# rather than raising (see pitfalls.md #17).
adapters = getattr(self, "adapters", None)
if not adapters:
return False
adapter = adapters.get(platform)
if adapter is None:
return False
return bool(getattr(adapter, "enforces_own_access_policy", False))
def _adapter_dm_policy(self, platform: Optional[Platform]) -> str:
"""Best-effort read of an own-policy adapter's effective DM policy.
Returns the lowercased ``dm_policy`` (``"open"`` / ``"allowlist"`` /
``"disabled"`` / ``"pairing"``) for *platform*, or ``""`` when unknown.
Prefers the live adapter's resolved ``_dm_policy`` — which already folds
in both ``config.extra`` and the ``<PLATFORM>_DM_POLICY`` env var (the
env var is not always bridged back into ``config.extra``) and falls
back to ``config.extra`` for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
so the gateway can run its pairing handshake, so "reached the gateway"
must not be read as "authorized".
"""
if not platform:
return ""
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
policy = getattr(adapter, "_dm_policy", None) if adapter is not None else None
if policy is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
policy = extra.get("dm_policy")
return str(policy or "").strip().lower()
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
Checks in order:
1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.)
3. DM pairing approved list
4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true)
5. Default: deny
"""
from gateway.run import logger
# Home Assistant events are system-generated (state changes), not
# user-initiated messages. The HASS_TOKEN already authenticates the
# connection, so HA events are always authorized.
# Webhook events are authenticated via HMAC signature validation in
# the adapter itself — no user allowlist applies.
if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}:
return True
user_id = source.user_id
# Telegram (and similar) authorize entire group/forum/channel chats
# by chat ID via TELEGRAM_GROUP_ALLOWED_CHATS / QQ_GROUP_ALLOWED_USERS.
# That allowlist is chat-scoped, so it must work even when
# source.user_id is None — Telegram emits anonymous-admin posts,
# sender_chat traffic, and channel broadcasts with no `from_user`,
# and an operator who explicitly listed the chat expects those to
# be honored. Run this check before the no-user-id guard below so
# documented behavior matches reality
# (website/docs/reference/environment-variables.md,
# website/docs/user-guide/messaging/telegram.md).
if source.chat_type in {"group", "forum", "channel"} and source.chat_id:
chat_allowlist_env = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}.get(source.platform, "")
if chat_allowlist_env:
raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip()
if raw_chat_allowlist:
allowed_group_ids = {
cid.strip()
for cid in raw_chat_allowlist.split(",")
if cid.strip()
}
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True
if not user_id:
return False
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
}
platform_group_user_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
}
platform_group_chat_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
Platform.SMS: "SMS_ALLOW_ALL_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
}
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
platform_allow_bots_map = {
Platform.DISCORD: "DISCORD_ALLOW_BOTS",
Platform.FEISHU: "FEISHU_ALLOW_BOTS",
}
# Plugin platforms: check the registry for auth env var names
if source.platform not in platform_env_map:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(source.platform.value)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
return True
# Check pairing store (always checked, regardless of allowlists)
platform_name = source.platform.value if source.platform else ""
if self.pairing_store.is_approved(platform_name, user_id):
return True
# Check platform-specific and global allowlists
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
group_user_allowlist = ""
group_chat_allowlist = ""
if source.chat_type in {"group", "forum"}:
group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip()
group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip()
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlists configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) already gated this message at
# intake — it would not have reached the gateway otherwise — so
# honor that decision instead of falling through to the
# env-only default-deny below, which would silently break
# `dm_policy: open` and config-only allowlists. (#34515)
if self._adapter_enforces_own_access_policy(source.platform):
# Exception: `dm_policy: pairing` does NOT authorize at intake.
# The adapter forwards the DM precisely so the gateway can run
# its pairing handshake (issue a code, consult the pairing
# store). The pairing-store approval check above already ran and
# returned False for this sender, so blanket-trusting the
# adapter here would silently turn pairing mode into open
# access. Fall through to default-deny so the unpaired sender is
# offered a pairing code instead. (Pairing is DM-only; group
# traffic keeps the adapter-trust path.)
if not (
source.chat_type == "dm"
and self._adapter_dm_policy(source.platform) == "pairing"
):
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
# Telegram can optionally authorize group traffic by chat ID.
# Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates
# the sender user ID for group/forum messages.
if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id:
allowed_group_ids = {
chat_id.strip() for chat_id in group_chat_allowlist.split(",") if chat_id.strip()
}
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True
# Backward-compat shim for #15027: prior to PR #17686,
# TELEGRAM_GROUP_ALLOWED_USERS was (mis)used as a chat-ID allowlist.
# Values starting with "-" are Telegram chat IDs, not user IDs, so if
# users still have those in TELEGRAM_GROUP_ALLOWED_USERS we honor them
# as chat IDs and warn once. The correct var is now
# TELEGRAM_GROUP_ALLOWED_CHATS.
if (
source.platform == Platform.TELEGRAM
and group_user_allowlist
and source.chat_type in {"group", "forum"}
and source.chat_id
):
legacy_chat_ids = {
v.strip()
for v in group_user_allowlist.split(",")
if v.strip().startswith("-")
}
if legacy_chat_ids:
if not getattr(self, "_warned_telegram_group_users_legacy", False):
logger.warning(
"TELEGRAM_GROUP_ALLOWED_USERS contains chat-ID-shaped values "
"(%s). Treating them as chat IDs for backward compatibility. "
"Move chat IDs to TELEGRAM_GROUP_ALLOWED_CHATS — the _USERS var "
"is now for sender user IDs.",
",".join(sorted(legacy_chat_ids)),
)
self._warned_telegram_group_users_legacy = True
if source.chat_id in legacy_chat_ids:
return True
# Check if user is in any allowlist. In group/forum chats,
# TELEGRAM_GROUP_ALLOWED_USERS is the scoped allowlist and should not
# imply DM access; TELEGRAM_ALLOWED_USERS remains the platform-wide
# allowlist and still works everywhere for backward compatibility.
allowed_ids = set()
if platform_allowlist:
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
if group_user_allowlist:
allowed_ids.update(uid.strip() for uid in group_user_allowlist.split(",") if uid.strip())
if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
# "*" in any allowlist means allow everyone (consistent with
# SIGNAL_GROUP_ALLOWED_USERS precedent)
if "*" in allowed_ids:
return True
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
if source.platform == Platform.WHATSAPP:
normalized_allowed_ids = set()
for allowed_id in allowed_ids:
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
if normalized_allowed_ids:
allowed_ids = normalized_allowed_ids
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
normalized_user_id = _normalize_whatsapp_identifier(user_id)
if normalized_user_id:
check_ids.add(normalized_user_id)
# SimpleX: SIMPLEX_ALLOWED_USERS accepts either the numeric contactId
# or the contact's display name. The adapter sets user_id=contactId for
# stability across renames, but the SimpleX UI never surfaces the
# numeric id — operators only see display names, so that's what they
# naturally put in the env var. Match both so the allowlist works
# regardless of which form was chosen.
# Plugin platform: compare by value since Platform.SIMPLEX is not a
# hardcoded enum member (it's a dynamic plugin platform).
if (
source.platform is not None
and source.platform.value == "simplex"
and source.user_name
):
check_ids.add(source.user_name)
return bool(check_ids & allowed_ids)
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
"""Return how unauthorized DMs should be handled for a platform.
Resolution order:
1. Explicit per-platform ``unauthorized_dm_behavior`` in config always wins.
2. Explicit global ``unauthorized_dm_behavior`` in config wins when no per-platform.
3. When an allowlist (``PLATFORM_ALLOWED_USERS``,
``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``,
or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"``
the allowlist signals that the owner has deliberately restricted
access; spamming unknown contacts with pairing codes is both noisy
and a potential info-leak. (#9337)
4. No allowlist and no explicit config ``"pair"`` (open-gateway default).
"""
config = getattr(self, "config", None)
# Check for an explicit per-platform override first.
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
# Operator explicitly configured behavior for this platform — respect it.
return config.get_unauthorized_dm_behavior(platform)
# Check for an explicit global config override.
if config and hasattr(config, "unauthorized_dm_behavior"):
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
return config.unauthorized_dm_behavior
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
# allowlist or disabled DM policy means the operator restricted access,
# so unauthorized DMs should be dropped silently rather than answered
# with a pairing code. An explicit pairing policy opts back into codes.
if platform and config and hasattr(config, "platforms"):
platform_cfg = config.platforms.get(platform)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
if dm_policy == "pairing":
return "pair"
if dm_policy in {"allowlist", "disabled"}:
return "ignore"
# No explicit override. Fall back to allowlist-aware default:
# if any allowlist is configured for this platform, silently drop
# unauthorized messages instead of sending pairing codes.
if platform:
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
}
platform_group_env_map = {
Platform.TELEGRAM: (
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
),
Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",),
}
if os.getenv(platform_env_map.get(platform, ""), "").strip():
return "ignore"
for env_key in platform_group_env_map.get(platform, ()):
if os.getenv(env_key, "").strip():
return "ignore"
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
return "ignore"
return "pair"
-57
View File
@@ -56,42 +56,6 @@ def _coerce_int(value: Any, default: int) -> int:
return default
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
"""Coerce an optional positive integer config value.
``None``/0/negative disable the setting. Malformed values are ignored with
a warning so a typo never prevents the gateway from starting.
"""
if value is None:
return None
if isinstance(value, bool):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
try:
if isinstance(value, float):
if not value.is_integer():
raise ValueError(value)
parsed = int(value)
elif isinstance(value, str):
parsed = int(value.strip(), 10)
else:
parsed = int(value)
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
if parsed <= 0:
return None
return parsed
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
"""Normalize unauthorized DM behavior to a supported value."""
if isinstance(value, str):
@@ -531,7 +495,6 @@ class GatewayConfig:
# Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions
# Unauthorized DM policy
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
@@ -637,7 +600,6 @@ class GatewayConfig:
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
"max_concurrent_sessions": self.max_concurrent_sessions,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
@@ -683,17 +645,6 @@ class GatewayConfig:
group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user")
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
if "max_concurrent_sessions" in data:
max_concurrent_raw = data.get("max_concurrent_sessions")
max_concurrent_key = "max_concurrent_sessions"
else:
max_concurrent_raw = nested_gateway.get("max_concurrent_sessions")
max_concurrent_key = "gateway.max_concurrent_sessions"
max_concurrent_sessions = _coerce_optional_positive_int(
max_concurrent_raw,
max_concurrent_key,
)
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
data.get("unauthorized_dm_behavior"),
"pair",
@@ -720,7 +671,6 @@ class GatewayConfig:
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
max_concurrent_sessions=max_concurrent_sessions,
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
@@ -811,13 +761,6 @@ def load_gateway_config() -> GatewayConfig:
if "thread_sessions_per_user" in yaml_cfg:
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
gateway_section = yaml_cfg.get("gateway")
if isinstance(gateway_section, dict) and "max_concurrent_sessions" in gateway_section:
gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"]
if "max_concurrent_sessions" in yaml_cfg:
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
streaming_cfg = yaml_cfg.get("streaming")
if not isinstance(streaming_cfg, dict):
# Fall back to nested gateway.streaming written by
+28 -39
View File
@@ -3510,46 +3510,35 @@ class APIServerAdapter(BasePlatformAdapter):
loop = asyncio.get_running_loop()
def _run():
from gateway.session_context import clear_session_vars, set_session_vars
tokens = set_session_vars(
platform="api_server",
chat_id=session_id or "",
session_key=gateway_session_key or session_id or "",
session_id=session_id or "",
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
)
try:
agent = self._create_agent(
ephemeral_system_prompt=ephemeral_system_prompt,
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
tool_start_callback=tool_start_callback,
tool_complete_callback=tool_complete_callback,
gateway_session_key=gateway_session_key,
)
if agent_ref is not None:
agent_ref[0] = agent
effective_task_id = session_id or str(uuid.uuid4())
result = agent.run_conversation(
user_message=user_message,
conversation_history=conversation_history,
task_id=effective_task_id,
)
usage = {
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
# Include the effective session ID in the result so callers
# (e.g. X-Hermes-Session-Id header) can track compression-
# triggered session rotations. (#16938)
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
return result, usage
finally:
clear_session_vars(tokens)
if agent_ref is not None:
agent_ref[0] = agent
effective_task_id = session_id or str(uuid.uuid4())
result = agent.run_conversation(
user_message=user_message,
conversation_history=conversation_history,
task_id=effective_task_id,
)
usage = {
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
# Include the effective session ID in the result so callers
# (e.g. X-Hermes-Session-Id header) can track compression-
# triggered session rotations. (#16938)
_eff_sid = getattr(agent, "session_id", session_id)
if isinstance(_eff_sid, str) and _eff_sid:
result["session_id"] = _eff_sid
return result, usage
return await loop.run_in_executor(None, _run)
+2 -5
View File
@@ -1795,12 +1795,9 @@ class BasePlatformAdapter(ABC):
# Whether this platform renders triple-backtick fenced code blocks (i.e.
# ``format_message`` translates/preserves markdown fences into a real code
# block). Capability flag for markdown-aware presentation choices.
# block). Drives presentation choices like rendering a ``terminal`` tool
# call's command as a ```bash block instead of a flat preview line.
# Default False (plain-text platforms); markdown-rendering adapters set True.
# Tool-progress uses this to render a terminal command as a bare fenced code
# block (no language tag — Slack mrkdwn would print the tag as a literal
# first code line). Plain-text platforms fall back to the short truncated
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
def __init__(self, config: PlatformConfig, platform: Platform):
+2 -10
View File
@@ -181,8 +181,6 @@ def _strip_mdv2(text: str) -> str:
"""
# Remove escape backslashes before special characters
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
# Remove standard markdown bold (**text** → text) BEFORE MarkdownV2 bold
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
# Remove MarkdownV2 bold markers that format_message converted from **bold**
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
# Remove MarkdownV2 italic markers that format_message converted from *italic*
@@ -2210,17 +2208,11 @@ class TelegramAdapter(BasePlatformAdapter):
# "Message is not modified" is a no-op, not an error
if "not modified" in str(fmt_err).lower():
return SendResult(success=True, message_id=message_id)
# Fallback: strip MarkdownV2 escapes and retry as clean plain text
logger.warning(
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
self.name,
fmt_err,
)
_plain = _strip_mdv2(content) if content else content
# Fallback: retry without markdown formatting
await self._bot.edit_message_text(
chat_id=int(chat_id),
message_id=int(message_id),
text=_plain,
text=content,
)
return SendResult(success=True, message_id=message_id)
except Exception as e:
+175 -271
View File
@@ -157,12 +157,6 @@ _YB_RES_REF_RE = re.compile(
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
)
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
# [image: /opt/data/image_cache/img_xxx.bmp]
# [file: report.pdf → /opt/data/.../report.pdf]
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
@@ -946,11 +940,7 @@ class InboundContext:
reply_to_text: Optional[str] = None
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
# Populated by MediaResolveMiddleware. Combined list of resolved local
# paths from up to three sources (deduped, in this order):
# 1) media carried by the current message (always),
# 2) media from the quoted message (when reply_to_message_id is set),
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
# Populated by MediaResolveMiddleware
media_urls: list = dc_field(default_factory=list)
media_types: list = dc_field(default_factory=list)
@@ -1695,10 +1685,10 @@ class ExtractContentMiddleware(InboundMiddleware):
"""Extract plain text content from MsgBody.
- TIMTextElem -> text field
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
- TIMImageElem -> "[image]"
- TIMFileElem -> "[file: {filename}]"
- TIMSoundElem -> "[voice]"
- TIMVideoFileElem -> "[video]"
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
- Multiple elems joined with spaces
@@ -2197,72 +2187,51 @@ class QuoteContextMiddleware(InboundMiddleware):
name = "quote-context"
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
@staticmethod
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
"""Extract quote context, mapping to MessageEvent.reply_to_*.
Returns:
(reply_to_message_id, reply_to_text, quote_media_refs)
where quote_media_refs is a list of (rid, kind, filename) tuples
"""
if not cloud_custom_data:
return None, None
return None, None, []
try:
parsed = json.loads(cloud_custom_data)
except (json.JSONDecodeError, TypeError):
return None, None
return None, None, []
quote = parsed.get("quote") if isinstance(parsed, dict) else None
if not isinstance(quote, dict):
return None, None
return None, None, []
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
quote_type = int(quote.get("type") or 0)
desc = str(quote.get("desc") or "").strip()
if quote_type == 2 and not desc:
desc = "[image]"
if not desc:
return None, None, []
quote_id = str(quote.get("id") or "").strip() or None
desc = str(quote.get("desc") or "").strip()
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
quote_text = f"{sender}: {desc}" if sender else desc
return quote_id, quote_text
# Extract media references from desc using _YB_RES_REF_RE regex
media_refs: list = []
for m in _YB_RES_REF_RE.finditer(desc):
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
media_refs.append((rid, kind, filename.strip()))
async def _extract_media_refs_from_transcript(
self, ctx: InboundContext
) -> List[Tuple[str, str, str]]:
"""Look up the quoted message in the transcript history and return any
``[kind|ybres:RID]`` anchors found in its content as
``(rid, kind, filename)`` tuples.
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
transcript store / source is unavailable, or when the quoted message
carries no resolvable media anchors.
"""
if ctx.reply_to_message_id is None:
return []
adapter = ctx.adapter
media_refs: List[Tuple[str, str, str]] = []
try:
store = getattr(adapter, "_session_store", None)
if not store or ctx.source is None:
return []
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if not mid or mid != ctx.reply_to_message_id:
continue
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
getattr(adapter, "name", "yuanbao"), exc,
)
return media_refs
return quote_id, quote_text, media_refs
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
await next_fn()
@@ -2467,6 +2436,11 @@ class MediaResolveMiddleware(InboundMiddleware):
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
@classmethod
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
return await cls._fetch_resource_url(adapter, resource_id)
@classmethod
async def _resolve_media_urls(
cls, adapter, media_refs: List[Dict[str, str]]
@@ -2482,7 +2456,6 @@ class MediaResolveMiddleware(InboundMiddleware):
for ref in media_refs:
kind = str(ref.get("kind") or "").strip().lower()
url = str(ref.get("url") or "").strip()
filename = str(ref.get("name") or "").strip()
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
continue
@@ -2502,7 +2475,7 @@ class MediaResolveMiddleware(InboundMiddleware):
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=filename or None,
file_name=str(ref.get("name") or "").strip() or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
@@ -2514,44 +2487,6 @@ class MediaResolveMiddleware(InboundMiddleware):
return media_urls, media_types
@classmethod
async def _resolve_ybres_refs(
cls,
adapter,
refs: List[Tuple[str, str, str]],
*,
log_prefix: str,
) -> Tuple[List[str], List[str]]:
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
"""
media_paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
)
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
@classmethod
async def _collect_observed_media(
cls, adapter, source,
@@ -2598,175 +2533,39 @@ class MediaResolveMiddleware(InboundMiddleware):
if not order:
return [], []
return await cls._resolve_ybres_refs(
adapter, order, log_prefix="observed-media",
)
@classmethod
async def _resolve_quote_media(
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
) -> Tuple[List[str], List[str]]:
"""Resolve media anchors carried by the quoted message.
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
produced by :class:`QuoteContextMiddleware` from the transcript.
"""
return await cls._resolve_ybres_refs(
adapter, quote_media_refs, log_prefix="quote",
)
@staticmethod
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
"""Private-chat fallback for recovering already-local quoted media.
Only already-local media is handled here: by the time a turn is cached,
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
``[image: /path]`` / ``[file: name /path]``. Unresolved anchors are an
original-turn resolution failure and belong to that turn's handling, not
this quote fallback so no re-download happens here.
Returns ``(local_paths, mimes)`` for media already downloaded to the
local cache on its original turn, ready to inject as-is.
"""
paths: List[str] = []
media_paths: List[str] = []
mimes: List[str] = []
rid_key = ctx.reply_to_message_id
if not rid_key:
return paths, mimes
cache = getattr(ctx.adapter, "_msg_content_cache", None)
if not cache:
return paths, mimes
text = cache.get(rid_key)
if not isinstance(text, str) or not text:
return paths, mimes
# Already-local media paths written by PatchAnchorsMiddleware. The
# generic anchor regex covers every kind _patch emits (image/file today,
# video/audio if they later become resolvable) without per-kind upkeep.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
path = (m.group(2) or "").strip()
if not path or path in seen:
continue
if not os.path.exists(path):
continue
seen.add(path)
mime = guess_mime_type(os.path.basename(path)) or (
"image/jpeg" if kind == "image" else "application/octet-stream"
)
paths.append(path)
mimes.append(mime)
return paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
# NOTE: Reaching this middleware in a group chat implies the message has
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
# short-circuits non-@bot group messages earlier in the pipeline, so we
# don't need to re-check @bot status here before downloading media.
adapter = ctx.adapter
urls: List[str] = []
types: List[str] = []
seen: set = set()
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
u_list, m_list = pair_lists
for u, m in zip(u_list, m_list):
if not u or u in seen:
continue
seen.add(u)
urls.append(u)
types.append(m)
# 1) Media carried by the current message itself.
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
own_count = sum(1 for u in own_pairs[0] if u)
_add_unique_pairs(own_pairs)
# 2) Second source — quoted media takes priority; otherwise fall back
# to observed-media backfill in groups only (DMs already had their
# media resolved on the turn it was sent).
if ctx.reply_to_message_id is not None:
if ctx.quote_media_refs:
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
else:
# DM quote fallback: no transcript message_id match (DM user rows
# carry no platform message_id), so recover already-local media
# from the adapter msg cache. Patched on its original turn — no
# re-download needed, inject as-is.
_add_unique_pairs(self._collect_quote_local_media(ctx))
elif ctx.chat_type == "group":
# Group chats: only @-bot turns reach this middleware
# (see GroupAtGuardMiddleware note at top of handle()),
# so unconditional observed-media hydration is safe here.
for rid, kind, filename in order:
try:
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
ctx.media_urls = urls
ctx.media_types = types
# Re-check placeholder after media resolution.
# Use ``own_count`` (not ``len(urls)``) to preserve the original
# semantics: a placeholder text accompanied only by quote/observed
# media (i.e. no fresh attachment of its own) is still skippable.
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
class PatchAnchorsMiddleware(InboundMiddleware):
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
``ctx.media_types`` are already populated with downloaded resources
(own media + quote media or group-observed media). The transcript
written downstream then records usable local paths for the model
instead of opaque ``ybres:`` references.
Only resolved media (paths starting with ``/``) are substituted; any
anchor without a corresponding local resource is left untouched.
"""
name = "patch-anchors"
@staticmethod
def _patch(text: str, urls: List[str], types: List[str]) -> str:
if not text or not urls:
return text
patched = text
for u, m in zip(urls, types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(patched)
if not anchor_match:
break
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
patched = (
patched[: anchor_match.start()]
+ replacement
+ patched[anchor_match.end():]
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"rid={rid}",
resource_id=rid,
)
return patched
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
adapter = ctx.adapter
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
# Re-check placeholder after media resolution
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
@@ -2785,18 +2584,124 @@ class DispatchMiddleware(InboundMiddleware):
)
async def _dispatch_inbound_event() -> None:
media_urls = list(ctx.media_urls)
media_types = list(ctx.media_types)
# If user quoted a message (reply_to_message_id is set), resolve only
# quote_media_refs to avoid injecting unrelated history media.
# Otherwise, backfill observed media from recent transcript history.
if ctx.reply_to_message_id is not None:
# Fallback: if desc didn't contain ybres refs, look up transcript
if not ctx.quote_media_refs:
try:
store = getattr(adapter, "_session_store", None)
if store:
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if mid and mid == ctx.reply_to_message_id:
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
ctx.quote_media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
adapter.name, exc,
)
# User quoted a message — resolve only media from the quote
for rid, kind, filename in ctx.quote_media_refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
continue
cached = await MediaResolveMiddleware._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"quote rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
# Avoid duplicates
if path not in media_urls:
media_urls.append(path)
media_types.append(mime)
else:
# No quote — backfill observed media from recent transcript history
extra_img_urls: List[str] = []
extra_img_mimes: List[str] = []
try:
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
adapter, ctx.source,
)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
if extra_img_urls:
current = set(media_urls)
for u, m in zip(extra_img_urls, extra_img_mimes):
if u in current:
continue
media_urls.append(u)
media_types.append(m)
current.add(u)
# Replace [kind|ybres:xxx] anchors with local cache paths so
# the transcript records usable paths for the model.
_patched_event_text = ctx.raw_text
for u, m in zip(media_urls, media_types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
if not anchor_match:
continue
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
_patched_event_text = (
_patched_event_text[:anchor_match.start()]
+ replacement
+ _patched_event_text[anchor_match.end():]
)
event = MessageEvent(
text=ctx.raw_text,
text=_patched_event_text,
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
if any(mt.startswith(("application/", "text/")) for mt in media_types)
else ctx.msg_type
),
source=ctx.source,
message_id=ctx.msg_id or None,
raw_message=ctx.push,
media_urls=list(ctx.media_urls),
media_types=list(ctx.media_types),
media_urls=media_urls,
media_types=media_types,
reply_to_message_id=ctx.reply_to_message_id,
reply_to_text=ctx.reply_to_text,
channel_prompt=ctx.channel_prompt,
@@ -2890,7 +2795,6 @@ class InboundPipelineBuilder:
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
]
+429 -436
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -106,7 +106,6 @@ def set_session_vars(
user_id: str = "",
user_name: str = "",
session_key: str = "",
session_id: str = "",
message_id: str = "",
cwd: str = "",
) -> list:
@@ -128,7 +127,6 @@ def set_session_vars(
_SESSION_USER_ID.set(user_id),
_SESSION_USER_NAME.set(user_name),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_MESSAGE_ID.set(message_id),
]
try:
@@ -159,7 +157,6 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_USER_ID,
_SESSION_USER_NAME,
_SESSION_KEY,
_SESSION_ID,
_SESSION_MESSAGE_ID,
):
var.set("")
-320
View File
@@ -1,320 +0,0 @@
"""Cross-process active chat session leases.
The session database records persisted conversations. This module records
currently open chat surfaces, including idle CLI/TUI sessions that have not
written a transcript row yet.
"""
from __future__ import annotations
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
def coerce_max_concurrent_sessions(value: Any, key: str = "max_concurrent_sessions") -> Optional[int]:
"""Return a positive integer cap, or None when disabled/invalid."""
if value is None:
return None
if isinstance(value, bool):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
try:
if isinstance(value, float):
if not value.is_integer():
raise ValueError(value)
parsed = int(value)
elif isinstance(value, str):
parsed = int(value.strip(), 10)
else:
parsed = int(value)
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
if parsed <= 0:
return None
return parsed
def resolve_max_concurrent_sessions(config: Any) -> Optional[int]:
"""Resolve top-level max_concurrent_sessions with gateway.* fallback."""
raw: Any = None
key = "max_concurrent_sessions"
if isinstance(config, dict):
if "max_concurrent_sessions" in config:
raw = config.get("max_concurrent_sessions")
else:
gateway_cfg = config.get("gateway")
if isinstance(gateway_cfg, dict):
raw = gateway_cfg.get("max_concurrent_sessions")
key = "gateway.max_concurrent_sessions"
else:
raw = getattr(config, "max_concurrent_sessions", None)
return coerce_max_concurrent_sessions(raw, key=key)
def active_session_limit_message(active_count: int, max_sessions: int) -> str:
return (
f"Hermes is at the active session limit ({active_count}/{max_sessions}). "
"Try again when another session finishes."
)
def _state_dir() -> Path:
return get_hermes_home() / "runtime"
def _state_path() -> Path:
return _state_dir() / "active_sessions.json"
def _lock_path() -> Path:
return _state_dir() / "active_sessions.lock"
class _FileLock:
def __init__(self, path: Path):
self.path = path
self._fh = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fh = open(self.path, "a+b")
if os.name == "nt":
try:
import msvcrt
self._fh.seek(0)
msvcrt.locking(self._fh.fileno(), msvcrt.LK_LOCK, 1)
except Exception as exc:
self._fh.close()
self._fh = None
raise RuntimeError("active session file lock unavailable") from exc
else:
try:
import fcntl
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
except Exception as exc:
self._fh.close()
self._fh = None
raise RuntimeError("active session file lock unavailable") from exc
return self
def __exit__(self, exc_type, exc, tb):
if self._fh is None:
return
if os.name == "nt":
try:
import msvcrt
self._fh.seek(0)
msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1)
except Exception:
pass
else:
try:
import fcntl
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
except Exception:
pass
try:
self._fh.close()
finally:
self._fh = None
def _read_entries(path: Path) -> list[dict[str, Any]]:
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
return []
except Exception:
logger.warning("Ignoring corrupt active session registry at %s", path)
return []
entries = data.get("entries") if isinstance(data, dict) else data
if not isinstance(entries, list):
return []
return [entry for entry in entries if isinstance(entry, dict)]
def _write_entries(path: Path, entries: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump({"entries": entries}, fh, sort_keys=True)
os.replace(tmp, path)
def _process_start_time(pid: int) -> Optional[float]:
# Pair pid with process create_time when psutil can read it, so a recycled
# pid does not keep a stale lease alive indefinitely.
try:
import psutil # type: ignore
return float(psutil.Process(pid).create_time())
except Exception:
return None
def _optional_float(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _pid_alive(pid: Any, process_start_time: Any = None) -> bool:
try:
pid_int = int(pid)
except (TypeError, ValueError):
return False
if pid_int <= 0:
return False
try:
from gateway.status import _pid_exists
exists = bool(_pid_exists(pid_int))
except Exception:
return False
if not exists:
return False
expected_start = _optional_float(process_start_time)
if expected_start is None:
return True
current_start = _process_start_time(pid_int)
if current_start is None:
return True
return abs(current_start - expected_start) < 0.001
def _prune_dead(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
entry
for entry in entries
if _pid_alive(entry.get("pid"), entry.get("process_start_time"))
]
@dataclass
class ActiveSessionLease:
lease_id: str
session_id: str
surface: str
enabled: bool = True
released: bool = False
def release(self) -> None:
if self.released or not self.enabled:
return
release_active_session(self)
def try_acquire_active_session(
*,
session_id: str,
surface: str,
config: Any,
metadata: Optional[dict[str, Any]] = None,
) -> tuple[Optional[ActiveSessionLease], Optional[str]]:
"""Acquire an active-session slot.
Returns ``(lease, None)`` on success. When the cap is disabled, the lease is
a no-op object so callers can unconditionally call ``release()``.
"""
max_sessions = resolve_max_concurrent_sessions(config)
lease_id = uuid.uuid4().hex
if max_sessions is None:
return ActiveSessionLease(
lease_id=lease_id,
session_id=session_id,
surface=surface,
enabled=False,
), None
now = time.time()
entry = {
"lease_id": lease_id,
"session_id": str(session_id),
"surface": str(surface),
"pid": os.getpid(),
"process_start_time": _process_start_time(os.getpid()),
"started_at": now,
"updated_at": now,
}
if metadata:
entry["metadata"] = {
str(k): v for k, v in metadata.items() if isinstance(k, str)
}
state_path = _state_path()
with _FileLock(_lock_path()):
raw_entries = _read_entries(state_path)
entries = _prune_dead(raw_entries)
pruned = len(raw_entries) - len(entries)
if pruned:
logger.info("Pruned %d stale active session lease(s)", pruned)
active_count = len(entries)
if active_count >= max_sessions:
_write_entries(state_path, entries)
logger.info(
"Active session limit reached: active=%d max=%d surface=%s",
active_count,
max_sessions,
surface,
)
return None, active_session_limit_message(active_count, max_sessions)
entries.append(entry)
_write_entries(state_path, entries)
return ActiveSessionLease(
lease_id=lease_id,
session_id=str(session_id),
surface=str(surface),
), None
def release_active_session(lease: ActiveSessionLease) -> None:
state_path = _state_path()
try:
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path))
kept = [
entry
for entry in entries
if str(entry.get("lease_id") or "") != lease.lease_id
]
if len(kept) != len(entries):
_write_entries(state_path, kept)
finally:
lease.released = True
def active_session_registry_snapshot() -> list[dict[str, Any]]:
"""Return the pruned active-session registry for diagnostics/tests."""
state_path = _state_path()
with _FileLock(_lock_path()):
entries = _prune_dead(_read_entries(state_path))
_write_entries(state_path, entries)
return entries
+17 -91
View File
@@ -1182,24 +1182,6 @@ def _store_provider_state(
auth_store["active_provider"] = provider_id
def mark_provider_active_if_unset(provider_id: str) -> None:
"""Set ``active_provider`` to *provider_id* only when none is set yet.
Used by ``hermes auth add`` OAuth paths that create credential-pool
entries directly (no singleton ``providers.<id>`` block). Adding the
very first credential for a provider should make it the active provider
so the setup wizard's ``_model_section_has_credentials()`` check (which
consults ``get_active_provider()``) does not report "No inference
provider configured". Subsequent adds for an already-active setup leave
the user's chosen active provider untouched.
"""
with _auth_store_lock():
auth_store = _load_auth_store()
if not (auth_store.get("active_provider") or "").strip():
auth_store["active_provider"] = provider_id
_save_auth_store(auth_store)
def is_known_auth_provider(provider_id: str) -> bool:
normalized = (provider_id or "").strip().lower()
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
@@ -1579,21 +1561,6 @@ def resolve_provider(
if has_usable_secret(os.getenv("OPENAI_API_KEY")) or has_usable_secret(os.getenv("OPENROUTER_API_KEY")):
return "openrouter"
# Auto-detect an OpenRouter credential added via `hermes auth add openrouter`
# (manual pool entry, no env var). Without this, a key that only lives in
# the credential pool is invisible to auto-detection — the user sees
# `hermes auth list` showing the credential while requests go out with no
# Authorization header ("HTTP 401: Missing Authentication header"). The
# env-var check above only covers keys exported as OPENROUTER_API_KEY /
# OPENAI_API_KEY. See issue #42130.
try:
from agent.credential_pool import load_pool as _load_pool
if _load_pool("openrouter").has_credentials():
return "openrouter"
except Exception as e:
logger.debug("Could not check OpenRouter credential pool: %s", e)
# Auto-detect API-key providers by checking their env vars
for pid, pconfig in PROVIDER_REGISTRY.items():
if pconfig.auth_type != "api_key":
@@ -3373,7 +3340,6 @@ def _sync_codex_pool_entries(
auth_store: Dict[str, Any],
tokens: Dict[str, str],
last_refresh: Optional[str],
previous_singleton_tokens: Optional[Dict[str, str]] = None,
) -> None:
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
@@ -3389,34 +3355,24 @@ def _sync_codex_pool_entries(
OAuth flow when the user logged in via ``hermes setup`` / the model
picker. Always synced with the fresh tokens.
* ``manual:device_code`` entries created by ``hermes auth add openai-codex``
that use the same device-code OAuth mechanism. ONLY synced if the
entry's existing access_token matches the *previous* singleton
access_token (i.e. the entry is a legacy singleton-alias from the
#33000 workaround era). Manual entries whose tokens never matched the
singleton represent INDEPENDENT accounts added via
``hermes auth add openai-codex`` and must not be overwritten by a
re-auth that targeted a different account (regression for #39236).
The original #33538 fix refreshed every ``manual:device_code`` entry
unconditionally. That worked when ``manual:device_code`` only meant
"legacy alias of the singleton", but the same source string is now
also produced by independent-account additions, and the broad sync
silently clobbered distinct accounts with the latest-authenticated
token pair. The access_token-match check distinguishes the two cases
without changing the source-string contract.
that use the same device-code OAuth mechanism. An interactive re-auth
proves the user owns the ChatGPT account, so it is safe (and expected)
to refresh these entries too. Without this, a user who once ran the
``hermes auth add`` workaround for #33000 would silently leave that
manual entry stale on every subsequent re-auth, recreating the issue
reported in #33538.
What does NOT get refreshed:
* ``manual:api_key`` and any other non-device-code manual sources those
are independent credentials (an explicit API key, a different ChatGPT
account, etc.) and must not be overwritten by a single re-auth.
* ``manual:device_code`` entries whose access_token does NOT match the
previous singleton see above; these are independent accounts.
Error markers (``last_status``, ``last_error_*``) are cleared ONLY on
entries that actually had their tokens rewritten by this re-auth.
Independent entries keep their own error state (their 401/429 markers
belong to that account's own auth flow, not this re-auth).
Error markers (``last_status``, ``last_error_*``) are also cleared on
every device-code-backed entry even those whose tokens we did not
rewrite so that an interactive re-auth gives every relevant pool entry
a fresh selection chance instead of leaving them marked unhealthy from a
pre-re-auth 401.
"""
access_token = tokens.get("access_token")
if not access_token:
@@ -3428,34 +3384,15 @@ def _sync_codex_pool_entries(
entries = pool.get("openai-codex")
if not isinstance(entries, list):
return
# Previous singleton access_token (before this re-auth overwrote it) —
# used to distinguish legacy singleton-aliases from independent accounts.
# When None or empty, no manual entry can be treated as an alias (which
# is the right default for first-ever-save or a freshly initialized
# auth.json).
prev_at = None
if isinstance(previous_singleton_tokens, dict):
prev_at = previous_singleton_tokens.get("access_token") or None
# Sources whose tokens should be rewritten by a fresh Codex device-code
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
# excluded — they represent independent credentials.
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
for entry in entries:
if not isinstance(entry, dict):
continue
source = entry.get("source")
if source == "device_code":
# Singleton-seeded mirror — always refresh.
refresh_this_entry = True
elif source == "manual:device_code":
# Refresh only if this entry's existing access_token matches the
# previous singleton access_token (i.e. it is a true alias of the
# singleton from the #33000 workaround era). An entry with its
# own distinct token material is an independent account and must
# be left alone (#39236).
refresh_this_entry = bool(
prev_at and entry.get("access_token") == prev_at
)
else:
# ``manual:api_key`` and any future non-device-code sources.
refresh_this_entry = False
if not refresh_this_entry:
if source not in REFRESHABLE_SOURCES:
continue
entry["access_token"] = access_token
if refresh_token:
@@ -3477,24 +3414,13 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "openai-codex") or {}
# Capture the previous singleton tokens BEFORE overwriting them. The
# pool-sync step uses this to distinguish legacy singleton-aliases
# (which should be refreshed) from independent accounts that
# ``hermes auth add openai-codex`` created (which must not be
# overwritten — see #39236).
previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None
state["tokens"] = tokens
state["last_refresh"] = last_refresh
state["auth_mode"] = "chatgpt"
if label and str(label).strip():
state["label"] = str(label).strip()
_save_provider_state(auth_store, "openai-codex", state)
_sync_codex_pool_entries(
auth_store,
tokens,
last_refresh,
previous_singleton_tokens=previous_singleton_tokens,
)
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
_save_auth_store(auth_store)
+7 -28
View File
@@ -13,7 +13,6 @@ from agent.credential_pool import (
AUTH_TYPE_OAUTH,
CUSTOM_POOL_PREFIX,
SOURCE_MANUAL,
SOURCE_MANUAL_DEVICE_CODE,
STATUS_EXHAUSTED,
STRATEGY_FILL_FIRST,
STRATEGY_ROUND_ROBIN,
@@ -313,35 +312,15 @@ def auth_add_command(args) -> None:
creds["tokens"]["access_token"],
_oauth_default_label(provider, len(pool.entries()) + 1),
)
# Add a distinct, self-contained pool entry per account (matching the
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
# routing through the singleton ``_save_codex_tokens`` save path.
# The singleton round-trip collapsed every added account into the
# latest login: a second ``hermes auth add openai-codex`` overwrote
# the first account's singleton-mirrored ``device_code`` entry rather
# than creating an independent one (#39236). ``manual:device_code``
# entries refresh from their own token pair, so they need no singleton
# shadow.
entry = PooledCredential(
provider=provider,
id=uuid.uuid4().hex[:6],
label=label,
auth_type=AUTH_TYPE_OAUTH,
priority=0,
source=SOURCE_MANUAL_DEVICE_CODE,
access_token=creds["tokens"]["access_token"],
refresh_token=creds["tokens"].get("refresh_token"),
base_url=creds.get("base_url"),
auth_mod._save_codex_tokens(
creds["tokens"],
last_refresh=creds.get("last_refresh"),
label=label,
)
first_credential = not pool.entries()
pool.add_entry(entry)
# Adding the first Codex credential should make it the active provider
# (the old singleton save path did this implicitly via
# _save_provider_state). Subsequent adds leave the active provider as-is.
if first_credential:
auth_mod.mark_provider_active_if_unset(provider)
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
pool = load_pool(provider)
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
shown_label = entry.label if entry is not None else label
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
return
if provider == "xai-oauth":
-681
View File
@@ -1,681 +0,0 @@
"""Agent-construction and session-resume display methods for ``HermesCLI``.
Extracted from ``cli.py`` as part of the god-file decomposition campaign
(``~/.hermes/plans/god-file-decomposition.md``, Phase 4 step 2). This mixin holds
the agent lifecycle/setup cluster: runtime-credential resolution, per-turn agent
config, first-use agent construction, and resumed-session preload + history recap.
Behavior-neutral: every method is lifted verbatim from ``HermesCLI``. ``self.*``
calls resolve unchanged via the MRO. Neutral dependencies are imported at module
top level; ``cli.py``-internal helpers/constants are imported lazily inside each
method (``from cli import ...`` resolves at call time, when ``cli`` is fully
loaded) so this module never imports ``cli`` at import time -> no import cycle.
"""
from __future__ import annotations
import sys
from rich.markup import escape as _escape
class CLIAgentSetupMixin:
"""Agent construction + session-resume display methods for ``HermesCLI``."""
def _ensure_runtime_credentials(self) -> bool:
"""
Ensure runtime credentials are resolved before agent use.
Re-resolves provider credentials so key rotation and token refresh
are picked up without restarting the CLI.
Returns True if credentials are ready, False on auth failure.
"""
from cli import ChatConsole, _cprint, logger
from hermes_cli.runtime_provider import (
resolve_runtime_provider,
format_runtime_provider_error,
)
_primary_exc = None
runtime = None
try:
runtime = resolve_runtime_provider(
requested=self.requested_provider,
explicit_api_key=self._explicit_api_key,
explicit_base_url=self._explicit_base_url,
)
except Exception as exc:
_primary_exc = exc
# Primary provider auth failed — try fallback providers before giving up.
if runtime is None and _primary_exc is not None:
from hermes_cli.auth import AuthError
if isinstance(_primary_exc, AuthError):
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
for _fb in _fb_chain:
_fb_provider = (_fb.get("provider") or "").strip().lower()
_fb_model = (_fb.get("model") or "").strip()
if not _fb_provider or not _fb_model:
continue
try:
runtime = resolve_runtime_provider(requested=_fb_provider)
logger.warning(
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
_primary_exc, _fb_provider, _fb_model,
)
_cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
self.requested_provider = _fb_provider
self.model = _fb_model
_primary_exc = None
break
except Exception:
continue
if runtime is None:
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
ChatConsole().print(f"[bold red]{message}[/]")
return False
api_key = runtime.get("api_key")
base_url = runtime.get("base_url")
resolved_provider = runtime.get("provider", "openrouter")
resolved_api_mode = runtime.get("api_mode", self.api_mode)
resolved_acp_command = runtime.get("command")
resolved_acp_args = list(runtime.get("args") or [])
resolved_credential_pool = runtime.get("credential_pool")
# A callable api_key is a bearer-token provider (Azure Foundry
# Entra ID — ``azure_identity_adapter.build_token_provider``).
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
# invokes it before every request. Skip the string-only validation
# and placeholder substitution for callables.
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
# Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
# don't require authentication. When a base_url IS configured but
# no API key was found, use a placeholder so the OpenAI SDK
# doesn't reject the request and local servers just ignore it.
_source = runtime.get("source", "")
_has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url
if _has_custom_base:
api_key = "no-key-required"
logger.debug(
"No API key for custom endpoint %s (source=%s), "
"using placeholder — local servers typically ignore auth",
base_url, _source,
)
else:
print("\n⚠️ Provider resolver returned an empty API key. "
"Set OPENROUTER_API_KEY or run: hermes setup")
return False
if not isinstance(base_url, str) or not base_url:
print("\n⚠️ Provider resolver returned an empty base URL. "
"Check your provider config or run: hermes setup")
return False
credentials_changed = api_key != self.api_key or base_url != self.base_url
routing_changed = (
resolved_provider != self.provider
or resolved_api_mode != self.api_mode
or resolved_acp_command != self.acp_command
or resolved_acp_args != self.acp_args
)
self.provider = resolved_provider
self.api_mode = resolved_api_mode
self.acp_command = resolved_acp_command
self.acp_args = resolved_acp_args
self._credential_pool = resolved_credential_pool
self._provider_source = runtime.get("source")
self.api_key = api_key
self.base_url = base_url
# When a custom_provider entry carries an explicit `model` field,
# use it as the effective model name. Without this, running
# `hermes chat --model <provider-name>` sends the provider name
# (e.g. "my-provider") as the model string to the API instead of
# the configured model (e.g. "qwen3.6-plus"), causing 400 errors.
runtime_model = runtime.get("model")
if runtime_model and isinstance(runtime_model, str):
# Only use runtime model if: model is unset, or model equals provider name
should_use_runtime_model = (
not self.model or # No model configured yet
self.model == self.provider or # Model is the provider slug
self.model == runtime.get("name") # Model matches provider display name
)
if should_use_runtime_model:
self.model = runtime_model
# If model is still empty (e.g. user ran `hermes auth add openai-codex`
# without `hermes model`), fall back to the provider's first catalog
# model so the API call doesn't fail with "model must be non-empty".
if not self.model and resolved_provider:
try:
from hermes_cli.models import get_default_model_for_provider
_default = get_default_model_for_provider(resolved_provider)
if _default:
self.model = _default
logger.info(
"No model configured — defaulting to %s for provider %s",
_default, resolved_provider,
)
except Exception:
pass
# Normalize model for the resolved provider (e.g. swap non-Codex
# models when provider is openai-codex). Fixes #651.
model_changed = self._normalize_model_for_provider(resolved_provider)
# AIAgent/OpenAI client holds auth at init time, so rebuild if key,
# routing, or the effective model changed.
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
self.agent = None
self._active_agent_route_signature = None
return True
def _resolve_turn_agent_config(self, user_message: str) -> dict:
"""Build the effective model/runtime config for a single user turn.
Always uses the session's primary model/provider. If the user has
toggled `/fast` on and the current model supports Priority
Processing / Anthropic fast mode, attach `request_overrides` so the
API call is marked accordingly.
"""
from hermes_cli.models import resolve_fast_mode_overrides
runtime = {
"api_key": self.api_key,
"base_url": self.base_url,
"provider": self.provider,
"api_mode": self.api_mode,
"command": self.acp_command,
"args": list(self.acp_args or []),
"credential_pool": getattr(self, "_credential_pool", None),
}
route = {
"model": self.model,
"runtime": runtime,
"signature": (
self.model,
runtime["provider"],
runtime["base_url"],
runtime["api_mode"],
runtime["command"],
tuple(runtime["args"]),
),
}
service_tier = getattr(self, "service_tier", None)
if not service_tier:
route["request_overrides"] = None
return route
try:
overrides = resolve_fast_mode_overrides(route["model"])
except Exception:
overrides = None
route["request_overrides"] = overrides
return route
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
"""
Initialize the agent on first use.
When resuming a session, restores conversation history from SQLite.
Returns:
bool: True if successful, False otherwise
"""
from cli import AIAgent, ChatConsole, _DIM, _RST, _accent_hex, _cprint, _prepare_deferred_agent_startup, logger
if self.agent is not None:
return True
_prepare_deferred_agent_startup()
self._install_tool_callbacks()
self._ensure_tirith_security()
if not self._ensure_runtime_credentials():
return False
from hermes_cli.mcp_startup import wait_for_mcp_discovery
wait_for_mcp_discovery()
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
if self._session_db is None:
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception as e:
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
# If resuming, validate the session exists and load its history.
# _preload_resumed_session() may have already loaded it (called from
# run() for immediate display). In that case, conversation_history
# is non-empty and we skip the DB round-trip.
if self._resumed and self._session_db and not self.conversation_history:
session_meta = self._session_db.get_session(self.session_id)
# In quiet mode (`hermes chat -Q` / --quiet, surfaced via
# tool_progress_mode == "off"), resume status lines go to stderr
# so stdout stays machine-readable for automation wrappers that
# do `$(hermes chat -Q --resume <id> -q "...")`. Without this,
# the resume banner pollutes captured stdout. See #11793.
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
if not session_meta:
if _quiet_mode:
print(f"Session not found: {self.session_id}", file=sys.stderr)
print(
"Use a session ID from a previous CLI run (hermes sessions list).",
file=sys.stderr,
)
else:
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
return False
# If the requested session is the (empty) head of a compression
# chain, walk to the descendant that actually holds the messages.
# See #15000 and SessionDB.resolve_resume_session_id.
try:
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
except Exception:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
ChatConsole().print(
f"[dim]Session {_escape(self.session_id)} was compressed into "
f"{_escape(resolved_id)}; resuming the descendant with your "
f"transcript.[/dim]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
if resolved_meta:
session_meta = resolved_meta
restored = self._session_db.get_messages_as_conversation(self.session_id)
if restored:
restored = [m for m in restored if m.get("role") != "session_meta"]
self.conversation_history = restored
msg_count = len([m for m in restored if m.get("role") == "user"])
title_part = ""
if session_meta.get("title"):
title_part = f" \"{session_meta['title']}\""
if _quiet_mode:
print(
f"↻ Resumed session {self.session_id}{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages)",
file=sys.stderr,
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]↻ Resumed session[/] "
f"[bold]{_escape(self.session_id)}[/]"
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
)
self._restore_session_cwd(session_meta, quiet=_quiet_mode)
else:
if _quiet_mode:
print(
f"Session {self.session_id} found but has no messages. Starting fresh.",
file=sys.stderr,
)
else:
ChatConsole().print(
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
)
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
(self.session_id,),
)
self._session_db._conn.commit()
except Exception:
pass
try:
runtime = runtime_override or {
"api_key": self.api_key,
"base_url": self.base_url,
"provider": self.provider,
"api_mode": self.api_mode,
"command": self.acp_command,
"args": list(self.acp_args or []),
"credential_pool": getattr(self, "_credential_pool", None),
}
effective_model = model_override or self.model
self.agent = AIAgent(
model=effective_model,
api_key=runtime.get("api_key"),
base_url=runtime.get("base_url"),
provider=runtime.get("provider"),
api_mode=runtime.get("api_mode"),
acp_command=runtime.get("command"),
acp_args=runtime.get("args"),
credential_pool=runtime.get("credential_pool"),
max_tokens=self.max_tokens,
max_iterations=self.max_turns,
enabled_toolsets=self.enabled_toolsets,
disabled_toolsets=self.disabled_toolsets,
verbose_logging=self.verbose,
quiet_mode=not self.verbose,
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
prefill_messages=self.prefill_messages or None,
reasoning_config=self.reasoning_config,
service_tier=self.service_tier,
request_overrides=request_overrides,
providers_allowed=self._providers_only,
providers_ignored=self._providers_ignore,
providers_order=self._providers_order,
provider_sort=self._provider_sort,
provider_require_parameters=self._provider_require_params,
provider_data_collection=self._provider_data_collection,
openrouter_min_coding_score=self._openrouter_min_coding_score,
session_id=self.session_id,
platform="cli",
session_db=self._session_db,
clarify_callback=self._clarify_callback,
reasoning_callback=self._current_reasoning_callback(),
fallback_model=self._fallback_model,
thinking_callback=self._on_thinking,
checkpoints_enabled=self.checkpoints_enabled,
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
pass_session_id=self.pass_session_id,
skip_context_files=self.ignore_rules,
skip_memory=self.ignore_rules,
tool_progress_callback=self._on_tool_progress,
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
notice_callback=self._on_notice,
notice_clear_callback=self._on_notice_clear,
)
# Store reference for atexit memory provider shutdown
global _active_agent_ref
_active_agent_ref = self.agent
# Route agent status output through prompt_toolkit so ANSI escape
# sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
self.agent._print_fn = _cprint
# Hydrate credits notices at session OPEN (parity with the TUI), so a
# depletion / usage-band warning shows before the first message. The
# notice_callback is bound above → _on_notice renders the line. Idempotent
# + fail-open inside the helper; harmless for non-Nous providers.
try:
from agent.credits_tracker import seed_credits_at_session_start
seed_credits_at_session_start(self.agent)
except Exception:
pass
self._active_agent_route_signature = (
effective_model,
runtime.get("provider"),
runtime.get("base_url"),
runtime.get("api_mode"),
runtime.get("command"),
tuple(runtime.get("args") or ()),
)
# Force-create DB row on /title intent, then apply title.
if self._pending_title and self._session_db and self.agent:
try:
self.agent._ensure_db_session()
if self.agent._session_db_created:
self._session_db.set_session_title(self.session_id, self._pending_title)
_cprint(f" Session title applied: {self._pending_title}")
self._pending_title = None
# else: row creation failed transiently — keep _pending_title for retry
except (ValueError, Exception) as e:
_cprint(f" Could not apply pending title: {e}")
# Keep _pending_title so it can be retried after row creation succeeds
return True
except Exception as e:
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
return False
def _preload_resumed_session(self) -> bool:
"""Load a resumed session's history from the DB early (before first chat).
Called from run() so the conversation history is available for display
before the user sends their first message. Sets
``self.conversation_history`` and prints the one-liner status. Returns
True if history was loaded, False otherwise.
The corresponding block in ``_init_agent()`` checks whether history is
already populated and skips the DB round-trip.
"""
from cli import _accent_hex
if not self._resumed or not self._session_db:
return False
session_meta = self._session_db.get_session(self.session_id)
if not session_meta:
self._console_print(
f"[bold red]Session not found: {self.session_id}[/]"
)
self._console_print(
"[dim]Use a session ID from a previous CLI run "
"(hermes sessions list).[/]"
)
return False
# If the requested session is the (empty) head of a compression chain,
# walk to the descendant that actually holds the messages. See #15000.
try:
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
except Exception:
resolved_id = self.session_id
if resolved_id and resolved_id != self.session_id:
self._console_print(
f"[dim]Session {self.session_id} was compressed into "
f"{resolved_id}; resuming the descendant with your transcript.[/]"
)
self.session_id = resolved_id
resolved_meta = self._session_db.get_session(self.session_id)
if resolved_meta:
session_meta = resolved_meta
restored = self._session_db.get_messages_as_conversation(self.session_id)
if restored:
restored = [m for m in restored if m.get("role") != "session_meta"]
self.conversation_history = restored
msg_count = len([m for m in restored if m.get("role") == "user"])
title_part = ""
if session_meta.get("title"):
title_part = f' "{session_meta["title"]}"'
accent_color = _accent_hex()
self._console_print(
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
f"{title_part} "
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
f"{len(restored)} total messages)[/]"
)
self._restore_session_cwd(session_meta)
else:
accent_color = _accent_hex()
self._console_print(
f"[{accent_color}]Session {self.session_id} found but has no "
f"messages. Starting fresh.[/]"
)
return False
# Re-open the session (clear ended_at so it's active again)
try:
self._session_db._conn.execute(
"UPDATE sessions SET ended_at = NULL, end_reason = NULL "
"WHERE id = ?",
(self.session_id,),
)
self._session_db._conn.commit()
except Exception:
pass
return True
def _display_resumed_history(self):
"""Render a compact recap of previous conversation messages.
Uses Rich markup with dim/muted styling so the recap is visually
distinct from the active conversation. Caps the display at the
last ``MAX_DISPLAY_EXCHANGES`` user/assistant exchanges and shows
an indicator for earlier hidden messages.
"""
from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history
if not self.conversation_history:
return
# Check config: resume_display setting
if self.resume_display == "minimal":
return
# Read limits from config (with hardcoded defaults)
_disp = CLI_CONFIG.get("display", {})
MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10))
MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300))
MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200))
MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3))
SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True)
# Collect displayable entries (skip system, tool-result messages)
entries = [] # list of (role, display_text)
_last_asst_idx = None # index of last assistant entry
_last_asst_full = None # un-truncated display text for last assistant
for msg in self.conversation_history:
role = msg.get("role", "")
content = msg.get("content")
tool_calls = msg.get("tool_calls") or []
if role == "system":
continue
if role == "tool":
continue
if role == "user":
text = "" if content is None else str(content)
# Handle multimodal content (list of dicts)
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
parts.append(part.get("text", ""))
elif isinstance(part, dict) and part.get("type") == "image_url":
parts.append("[image]")
text = " ".join(parts)
if len(text) > MAX_USER_LEN:
text = text[:MAX_USER_LEN] + "..."
entries.append(("user", text))
elif role == "assistant":
text = "" if content is None else str(content)
text = _strip_reasoning_tags(text)
parts = []
full_parts = [] # un-truncated version
if text:
full_parts.append(text)
lines = text.splitlines()
if len(lines) > MAX_ASST_LINES:
text = "\n".join(lines[:MAX_ASST_LINES]) + " ..."
if len(text) > MAX_ASST_LEN:
text = text[:MAX_ASST_LEN] + "..."
parts.append(text)
if tool_calls:
tc_count = len(tool_calls)
# Extract tool names
names = []
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
if name not in names:
names.append(name)
names_str = ", ".join(names[:4])
if len(names) > 4:
names_str += ", ..."
noun = "call" if tc_count == 1 else "calls"
tc_summary = f"[{tc_count} tool {noun}: {names_str}]"
parts.append(tc_summary)
full_parts.append(tc_summary)
if not parts:
# Skip pure-reasoning messages that have no visible output
continue
# Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled
has_text = bool(text)
if SKIP_TOOL_ONLY and not has_text and tool_calls:
continue
entries.append(("assistant", " ".join(parts)))
_last_asst_idx = len(entries) - 1
_last_asst_full = " ".join(full_parts)
if not entries:
return
# Determine if we need to truncate
skipped = 0
if len(entries) > MAX_DISPLAY_EXCHANGES * 2:
skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2
entries = entries[skipped:]
# Replace last assistant entry with full (un-truncated) text
# so the user can see where they left off without wasting tokens.
if _last_asst_idx is not None and _last_asst_full:
adj_idx = _last_asst_idx - skipped
if 0 <= adj_idx < len(entries):
entries[adj_idx] = ("assistant_last", _last_asst_full)
# Build the display using Rich
from rich.panel import Panel
from rich.text import Text
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
_session_label_c = _skin.get_color("session_label", "#DAA520")
_session_border_c = _skin.get_color("session_border", "#8B8682")
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
except Exception:
_history_text_c = "#FFF8DC"
_session_label_c = "#DAA520"
_session_border_c = "#8B8682"
_assistant_label_c = "#8FBC8F"
lines = Text()
if skipped:
lines.append(
f" ... {skipped} earlier messages ...\n\n",
style="dim italic",
)
for i, (role, text) in enumerate(entries):
if role == "user":
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
# Show first line inline, indent rest
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
elif role == "assistant_last":
# Last assistant response shown in full, non-dim
lines.append(" ◆ Hermes: ", style=f"bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="")
else:
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
msg_lines = text.splitlines()
lines.append(msg_lines[0] + "\n", style="dim")
for ml in msg_lines[1:]:
lines.append(f" {ml}\n", style="dim")
if i < len(entries) - 1:
lines.append("") # small gap
panel = Panel(
lines,
title=f"[dim {_session_label_c}]Previous Conversation[/]",
border_style=f"dim {_session_border_c}",
padding=(0, 1),
style=_history_text_c,
)
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
with _suspend_output_history():
self._console_print(panel)
+38 -95
View File
@@ -13,7 +13,6 @@ This module provides:
"""
import copy
import json
import logging
import os
import platform
@@ -806,9 +805,6 @@ DEFAULT_CONFIG = {
"fallback_providers": [],
"credential_pool_strategies": {},
"toolsets": ["hermes-cli"],
# Global active chat session cap across CLI, TUI/dashboard, and messaging.
# None/0 = unbounded.
"max_concurrent_sessions": None,
"agent": {
"max_turns": 90,
# Inactivity timeout for gateway agent execution (seconds).
@@ -2936,6 +2932,14 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "tool",
},
"APIFY_API_TOKEN": {
"description": "Apify API token for Actor execution tools (apify_discover, apify_start, apify_collect)",
"prompt": "Apify API token",
"url": "https://apify.com/account/integrations",
"tools": ["apify_discover", "apify_start", "apify_collect"],
"password": True,
"category": "tool",
},
"SEARXNG_URL": {
"description": "URL of your SearXNG instance for free self-hosted web search",
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
@@ -5153,94 +5157,6 @@ def load_config_readonly() -> Dict[str, Any]:
return _load_config_impl(want_deepcopy=False)
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
"modal_mode": "TERMINAL_MODAL_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def apply_terminal_config_to_env(
*,
env: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None,
) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is intentionally environment-driven because it also
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
gives those child-process launch paths the same config bridge as classic
CLI without importing ``cli.py`` and paying for its startup side effects.
When the user config contains a ``terminal`` section, config.yaml is
authoritative and overrides existing env values. Otherwise defaults only
backfill missing env vars so exported/.env values keep working.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if raw_cwd in {".", "auto", "cwd"}:
continue
if isinstance(value, str):
value = os.path.expanduser(value)
if should_override or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
@@ -6129,9 +6045,36 @@ def set_config_value(key: str, value: str):
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# JSON-valued keys (terminal_tool parses these via json.loads). The user
# passes JSON on the CLI, so str(value) below already yields valid JSON —
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
print(f"✓ Set {key} = {value} in {config_path}")
+12 -139
View File
@@ -94,36 +94,19 @@ def _register_self_hosted_client(
*,
access_token: str,
portal_base_url: str,
name: Optional[str],
name: str,
custom_redirect_uri: Optional[str],
existing_client_id: Optional[str] = None,
timeout: float = 15.0,
) -> dict:
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
When ``existing_client_id`` is provided (the client_id this install
persisted on a prior run), it is sent so the portal updates that existing
dashboard record in place instead of minting a duplicate this is what
makes re-running ``hermes dashboard register`` idempotent. The portal
falls back to creating a fresh client if the id no longer resolves to a row
in the caller's org (stale/deleted), so passing it is always safe.
``name`` may be ``None`` on the idempotent update path (re-run without an
explicit ``--name``): omitting it tells the portal to keep the name it
already stored rather than overwriting it. It is required on the create
path; the caller guarantees a value there.
Raises RuntimeError with a user-facing message on any non-2xx response or
transport failure.
"""
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
body: dict[str, str] = {}
if name:
body["name"] = name
body: dict[str, str] = {"name": name}
if custom_redirect_uri:
body["custom_redirect_uri"] = custom_redirect_uri
if existing_client_id:
body["client_id"] = existing_client_id
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
@@ -182,20 +165,16 @@ def _print_post_register_hint(
portal_base_url: str,
custom_redirect_uri: Optional[str],
wrote_portal_url: bool,
public_url: str = "",
) -> None:
"""Print the success summary + the gate-engagement caveat."""
from hermes_cli.config import get_env_path
env_path = get_env_path()
_cid = client_id
print()
print(f" Wrote to {env_path}:")
print(" HERMES_DASHBOARD_OAUTH_CLIENT_ID=" + str(_cid))
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
if wrote_portal_url:
print(" HERMES_DASHBOARD_PORTAL_URL=" + str(portal_base_url))
if public_url:
print(" HERMES_DASHBOARD_PUBLIC_URL=" + str(public_url))
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
print()
print(
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
@@ -261,48 +240,12 @@ def cmd_dashboard_register(args) -> None:
# Portal override: explicit --portal-url flag wins, else the
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
#
# We track whether a custom URL was *explicitly supplied* (flag or env)
# separately from the resolved value. An explicit custom URL is an
# intentional choice the user wants to persist (and update in place if it
# already exists in .env); a portal merely inferred from the stored login
# keeps the older, more conservative write-only-if-absent behaviour so we
# don't clutter .env for the common production case.
portal_override = getattr(args, "portal_url", None) or os.environ.get(
"HERMES_DASHBOARD_PORTAL_URL"
)
custom_portal_supplied = bool(
isinstance(portal_override, str) and portal_override.strip()
)
portal_base_url = _resolve_portal_base_url(portal_override)
# Idempotency: if this install already registered a dashboard, we hold its
# client_id locally (HERMES_DASHBOARD_OAUTH_CLIENT_ID). Re-send it so the
# portal UPDATES that existing record instead of creating a duplicate. No
# stored client_id -> this is a first registration -> create a fresh one
# (the original behavior). This mirrors the portal's rule: no client id =
# new dashboard; client id present = the stable key of the row to modify.
existing_client_id = None
try:
existing_client_id = get_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID")
except Exception:
existing_client_id = None
if isinstance(existing_client_id, str):
existing_client_id = existing_client_id.strip() or None
else:
existing_client_id = None
explicit_name = getattr(args, "name", None)
# Auto-generate a random name ONLY for a first registration. On a re-run
# (we hold a client_id) without an explicit --name, keep the name the
# portal already stored rather than churning it to a new random value
# every time — so leave `name` unset and let the portal preserve it.
if explicit_name:
name = explicit_name
elif existing_client_id:
name = None
else:
name = _generate_dashboard_name()
name = getattr(args, "name", None) or _generate_dashboard_name()
custom_redirect_uri = getattr(args, "redirect_uri", None)
# 2. Register with the portal.
@@ -312,26 +255,20 @@ def cmd_dashboard_register(args) -> None:
portal_base_url=portal_base_url,
name=name,
custom_redirect_uri=custom_redirect_uri,
existing_client_id=existing_client_id,
)
except RuntimeError as exc:
print(f"✗ Registration failed: {exc}")
sys.exit(1)
client_id = str(result["client_id"])
registered_name = str(result.get("name") or name or "")
registered_name = str(result.get("name") or name)
# Distinguish create vs update for the user: the portal echoes back the
# same client_id we sent when it updated in place.
updated_existing = bool(
existing_client_id and client_id == existing_client_id
)
if updated_existing:
print(f'✓ Updated dashboard "{registered_name}"')
else:
print(f'✓ Registered dashboard "{registered_name}"')
print(f'✓ Registered dashboard "{registered_name}"')
# 3. Write env vars idempotently. Always set the client_id.
# 3. Write env vars idempotently. Always set the client_id. Only set the
# portal URL when it isn't already configured (env or config) AND differs
# from the production default, so we don't clutter .env for the common case
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
try:
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
except Exception as exc:
@@ -339,18 +276,6 @@ def cmd_dashboard_register(args) -> None:
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
sys.exit(1)
# Persist the portal URL. Two cases:
# a) The user explicitly supplied a custom portal (--portal-url flag or
# HERMES_DASHBOARD_PORTAL_URL env). That's an intentional choice we
# always persist so it survives across sessions — overwriting any
# existing entry in place (save_env_value updates a matching key
# rather than appending a duplicate). This is true even when it equals
# the production default: the user asked for it explicitly.
# b) No custom portal was supplied. Keep the older conservative behaviour:
# only write a portal inferred from the stored login when it isn't
# already configured AND differs from the production default, so we
# don't clutter .env for the common production case and don't alter an
# existing entry unexpectedly.
wrote_portal_url = False
default_portal = "https://portal.nousresearch.com"
existing_portal = None
@@ -358,15 +283,7 @@ def cmd_dashboard_register(args) -> None:
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
except Exception:
existing_portal = None
if custom_portal_supplied:
should_write_portal = existing_portal != portal_base_url
else:
should_write_portal = (
not existing_portal and portal_base_url.rstrip("/") != default_portal
)
if should_write_portal:
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
try:
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
wrote_portal_url = True
@@ -374,54 +291,10 @@ def cmd_dashboard_register(args) -> None:
# Non-fatal: the client_id is the load-bearing value.
pass
# Persist the dashboard public URL derived from the OAuth redirect URI.
#
# --redirect-uri is the full public HTTPS callback the user registered with
# the portal, e.g. https://hermes.example.com/auth/callback. At serve time
# the dashboard auth layer (dashboard_auth/routes._redirect_uri) reconstructs
# that same callback by taking HERMES_DASHBOARD_PUBLIC_URL and appending
# "/auth/callback" verbatim. So the value the runtime actually consumes is
# the ORIGIN (scheme://host[:port]), not the full callback path — persisting
# the raw redirect URI would double up the path. We derive the origin from
# the supplied redirect URI and persist it as HERMES_DASHBOARD_PUBLIC_URL so
# the operator doesn't have to re-supply it and the public-URL override is
# actually wired (the gate engages and the callback round-trips correctly).
#
# Like the portal URL, an explicitly supplied value is always written
# (updating an existing entry in place rather than appending a duplicate),
# a no-op when it already matches, and never written on a localhost-only
# install (no --redirect-uri).
wrote_public_url = False
public_url = ""
if custom_redirect_uri:
try:
from urllib.parse import urlparse
parsed = urlparse(custom_redirect_uri)
if parsed.scheme in ("http", "https") and parsed.netloc:
public_url = f"{parsed.scheme}://{parsed.netloc}"
except Exception:
public_url = ""
if public_url:
existing_public_url = None
try:
existing_public_url = get_env_value("HERMES_DASHBOARD_PUBLIC_URL")
except Exception:
existing_public_url = None
if existing_public_url != public_url:
try:
save_env_value("HERMES_DASHBOARD_PUBLIC_URL", public_url)
wrote_public_url = True
except Exception:
# Non-fatal: the client_id is the load-bearing value.
pass
# 4. Hint.
_print_post_register_hint(
client_id=client_id,
portal_base_url=portal_base_url,
custom_redirect_uri=custom_redirect_uri,
wrote_portal_url=wrote_portal_url,
public_url=public_url if wrote_public_url else "",
)
+4 -12
View File
@@ -738,14 +738,11 @@ def run_doctor(args):
issues,
)
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them.
# Vendor/model slugs are valid on aggregator-style providers and on any custom
# provider — bare "custom" or a named "custom:<name>" that fronts an OpenAI-compatible
# aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
provider_for_policy = runtime_provider or catalog_provider
provider_policy_id = str(provider_for_policy or "").strip().lower()
providers_accepting_vendor_slugs = {
"openrouter",
"custom",
"auto",
"kilocode",
"opencode-zen",
@@ -753,16 +750,11 @@ def run_doctor(args):
"lmstudio",
"nous",
}
provider_accepts_vendor_slug = (
provider_policy_id in providers_accepting_vendor_slugs
or provider_policy_id == "custom"
or provider_policy_id.startswith("custom:")
)
if (
default_model
and "/" in default_model
and provider_policy_id
and not provider_accepts_vendor_slug
and provider_for_policy
and provider_for_policy not in providers_accepting_vendor_slugs
):
check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
-11
View File
@@ -318,17 +318,6 @@ def run_dump(args):
display = _redact(val)
else:
display = "set" if val else "not set"
# A credential added via `hermes auth add openrouter` lives in the
# credential pool, not as an env var — surface it so the dump doesn't
# misleadingly read "not set" while `hermes auth list` shows it (#42130).
if not val and label == "openrouter":
try:
from agent.credential_pool import load_pool as _load_pool
if _load_pool("openrouter").has_credentials():
display = "set (auth pool)"
except Exception:
pass
lines.append(f" {label:<20} {display}")
# Features summary
+2607 -180
View File
File diff suppressed because it is too large Load Diff
-31
View File
@@ -356,37 +356,6 @@ def get_curated_nous_models() -> list[str] | None:
return out or None
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
"""Overwrite the disk cache with the catalog shipped in a local checkout.
``hermes update`` pulls the latest repo, so the freshly-pulled
``website/static/api/model-catalog.json`` IS the newest catalog no
network round-trip needed. Copying it straight over the disk cache keeps
the model picker current even when the remote manifest fetch is bot-gated
or the Portal hiccups.
Reads the shipped manifest, validates it against the schema, and writes it
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
network path uses. Returns ``True`` on success, ``False`` if the file is
missing, malformed, or fails validation (caller should treat a ``False``
as non-fatal the network fetch path still applies on the next picker
open).
"""
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
try:
with open(src, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
return False
if not _validate_manifest(data):
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
return False
_write_disk_cache(data)
reset_cache() # drop the in-process copy so the next read picks up the seed
return True
def reset_cache() -> None:
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
global _catalog_cache, _catalog_cache_source_mtime
File diff suppressed because it is too large Load Diff
+6 -102
View File
@@ -33,7 +33,6 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-fable-5", ""),
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-sonnet-4.6", ""),
@@ -74,10 +73,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("poolside/laguna-m.1:free", "free"),
("tencent/hy3-preview:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("inclusionai/ring-2.6-1t:free", "free"),
]
@@ -155,7 +152,6 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-fable-5",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
@@ -326,7 +322,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2",
],
"anthropic": [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
@@ -769,64 +764,6 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
def _nous_recommended_disk_path() -> "Path":
"""Disk path for the persisted recommended-models cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
"""Return the last-known-good payload for ``base`` from disk, or None.
The disk file is a JSON object keyed by portal base URL so staging and
prod don't collide:
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
"""
try:
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
blob = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(blob, dict):
return None
entry = blob.get(base)
if not isinstance(entry, dict):
return None
data = entry.get("data")
return data if isinstance(data, dict) and data else None
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
"""Persist ``data`` as the last-known-good payload for ``base``.
Merges into any existing per-base map, then writes atomically. Failures
are non-fatal (logged at debug) the in-process cache still works.
"""
if not data:
return
path = _nous_recommended_disk_path()
try:
try:
with open(path, encoding="utf-8") as fh:
blob = json.load(fh)
if not isinstance(blob, dict):
blob = {}
except (OSError, json.JSONDecodeError):
blob = {}
blob[base] = {"data": data, "ts": time.time()}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(blob, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
except OSError as exc:
import logging
logging.getLogger(__name__).debug(
"nous recommended-models disk cache write failed: %s", exc
)
def fetch_nous_recommended_models(
portal_base_url: str = "",
timeout: float = 5.0,
@@ -837,19 +774,12 @@ def fetch_nous_recommended_models(
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public
no auth is required. Results are cached per portal URL for
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
``force_refresh=True`` to bypass the in-process cache.
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
bypass the cache.
A successful live fetch is also persisted to a per-base disk cache
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.
When the live fetch fails (network, parse, non-2xx) and the in-process
cache is empty, the disk copy is returned instead of ``{}`` so a
transient Portal hiccup no longer silently drops the free/paid model
recommendations from the picker. Self-heals on the next successful fetch.
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
any cache layer can supply data. Callers must treat missing/null fields
as "no recommendation" and fall back to their own default.
Returns the parsed JSON dict on success, or ``{}`` on any failure
(network, parse, non-2xx). Callers must treat missing/null fields as
"no recommendation" and fall back to their own default.
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
now = time.monotonic()
@@ -872,19 +802,6 @@ def fetch_nous_recommended_models(
except Exception:
data = {}
if data:
# Live fetch succeeded — refresh both cache layers.
_nous_recommended_cache[base] = (data, now)
_write_nous_recommended_disk(base, data)
return data
# Live fetch failed. Fall back to the last-known-good disk copy so a
# transient Portal hiccup doesn't drop the recommendations entirely.
disk = _read_nous_recommended_disk(base)
if disk:
_nous_recommended_cache[base] = (disk, now)
return disk
_nous_recommended_cache[base] = (data, now)
return data
@@ -2220,20 +2137,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if normalized == "anthropic":
live = _fetch_anthropic_models()
if live:
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
# first, then append any live-only models, so a fresh curated
# model never disappears just because the API hasn't listed it yet.
curated = list(_PROVIDER_MODELS.get("anthropic", []))
merged = list(curated)
merged_lower = {m.lower() for m in curated}
for m in live:
if m.lower() not in merged_lower:
merged.append(m)
merged_lower.add(m.lower())
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
return live
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
+106 -223
View File
@@ -135,89 +135,34 @@ def _sanitize_plugin_name(
return target
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
Returns ``(git_url, subdir)`` where ``subdir`` is the path within the
cloned repository that contains the plugin (``None`` when the plugin lives
at the repo root).
def _resolve_git_url(identifier: str) -> str:
"""Turn an identifier into a cloneable Git URL.
Accepted formats:
- Full URL: https://github.com/owner/repo.git
- Full URL: git@github.com:owner/repo.git
- Full URL: ssh://git@github.com/owner/repo.git
- Shorthand: owner/repo https://github.com/owner/repo.git
- Shorthand w/ subdir: owner/repo/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
- Full URL w/ subdir (``.git`` boundary):
https://github.com/owner/repo.git/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
- Any URL w/ explicit subdir fragment (works for every scheme, incl.
``file://`` and ssh): <url>#path/to/plugin
(<url>, "path/to/plugin")
NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
security warning at install time.
"""
# Already a URL.
# Already a URL
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
if "#" in identifier:
git_url, _, frag = identifier.partition("#")
return git_url, (frag.strip("/") or None)
# Natural ``.git/`` boundary (GitHub-style URLs).
marker = ".git/"
idx = identifier.find(marker)
if idx != -1:
git_url = identifier[: idx + len(".git")]
subdir = identifier[idx + len(marker) :].strip("/")
return git_url, (subdir or None)
return identifier, None
return identifier
# owner/repo[/subdir...] shorthand
parts = [p for p in identifier.strip("/").split("/") if p]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
subdir = "/".join(parts[2:]).strip("/")
git_url = f"https://github.com/{owner}/{repo}.git"
return git_url, (subdir or None)
# owner/repo shorthand
parts = identifier.strip("/").split("/")
if len(parts) == 2:
owner, repo = parts
return f"https://github.com/{owner}/{repo}.git"
raise ValueError(
f"Invalid plugin identifier: '{identifier}'. "
"Use a Git URL or 'owner/repo' shorthand (optionally with a subdirectory: "
"'owner/repo/path/to/plugin')."
"Use a Git URL or owner/repo shorthand."
)
def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path:
"""Resolve ``subdir`` inside ``clone_root``, rejecting path traversal.
Guards against ``..`` segments, absolute paths, and symlinks that would
escape the cloned repository. Returns the resolved directory path.
Raises ``PluginOperationError`` if the path escapes the clone, doesn't
exist, or is not a directory.
"""
clone_root = clone_root.resolve()
candidate = (clone_root / subdir).resolve()
# The resolved candidate must stay within the clone root.
if candidate != clone_root and clone_root not in candidate.parents:
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' escapes the repository.",
)
if not candidate.exists():
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' does not exist in the repository.",
)
if not candidate.is_dir():
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' is not a directory.",
)
return candidate
def _repo_name_from_url(url: str) -> str:
"""Extract the repo name from a Git URL for the plugin directory name."""
# Strip trailing .git and slashes
@@ -427,14 +372,14 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
import tempfile
try:
git_url, subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
plugins_dir = _plugins_dir()
with tempfile.TemporaryDirectory() as tmp:
tmp_clone = Path(tmp) / "plugin"
tmp_target = Path(tmp) / "plugin"
git_exe = _resolve_git_executable()
if not git_exe:
@@ -442,7 +387,7 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
try:
result = subprocess.run(
[git_exe, "clone", "--depth", "1", git_url, str(tmp_clone)],
[git_exe, "clone", "--depth", "1", git_url, str(tmp_target)],
capture_output=True,
text=True,
timeout=60,
@@ -460,16 +405,8 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
err = (result.stderr or result.stdout or "").strip()
raise PluginOperationError(f"Git clone failed:\n{err}")
# Resolve the directory within the clone that holds the plugin.
if subdir:
tmp_target = _resolve_subdir_within(tmp_clone, subdir)
else:
tmp_target = tmp_clone
manifest = _read_manifest(tmp_target)
plugin_name = manifest.get("name") or (
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
)
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
@@ -534,7 +471,7 @@ def cmd_install(
console = Console()
try:
git_url, _subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
@@ -545,10 +482,7 @@ def cmd_install(
"Consider using https:// or git@ for production installs.",
)
if _subdir:
console.print(f"[dim]Cloning {git_url} (subdir: {_subdir})...[/dim]")
else:
console.print(f"[dim]Cloning {git_url}...[/dim]")
console.print(f"[dim]Cloning {git_url}...[/dim]")
try:
target, installed_manifest, installed_name = _install_plugin_core(
@@ -715,62 +649,29 @@ def _save_enabled_set(enabled: set) -> None:
save_config(config)
def _resolve_plugin_key(name: str) -> Optional[str]:
"""Resolve a user-supplied plugin identifier to its canonical registry key.
Accepts either the bare manifest name (``nemo_relay``), the directory
name, or the full path-derived key (``observability/nemo_relay``) and
returns the canonical key the loader gates on (``manifest.key`` or, for a
flat plugin, the bare name). Returns ``None`` when no plugin matches.
This is the single normalization point so ``hermes plugins enable`` /
``disable`` write the same key that ``PluginManager`` matches against
nested category plugins (e.g. ``observability/nemo_relay``) included.
"""
entries = _discover_all_plugins()
# 1. Exact match on canonical key or manifest name — always unambiguous.
for entry in entries:
# entry = (name, version, description, source, dir_path, key)
if name == entry[5] or name == entry[0]:
return entry[5]
# 2. Fall back to a bare leaf-name match (e.g. "nemo_relay" ->
# "observability/nemo_relay"), but only when it resolves to exactly one
# plugin so we never silently pick the wrong same-named nested plugin.
leaf_matches = [entry[5] for entry in entries if name == entry[5].split("/")[-1]]
if len(leaf_matches) == 1:
return leaf_matches[0]
return None
def cmd_enable(name: str) -> None:
"""Add a plugin to the enabled allow-list (and remove it from disabled)."""
from rich.console import Console
console = Console()
# Discover the plugin — check installed (user) AND bundled, including
# nested category plugins — and normalize to its canonical registry key.
key = _resolve_plugin_key(name)
if key is None:
# Discover the plugin — check installed (user) AND bundled.
if not _plugin_exists(name):
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if key in enabled and key not in disabled:
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
if name in enabled and name not in disabled:
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
return
enabled.add(key)
disabled.discard(key)
# Drop any legacy bare-name entry so the two don't drift out of sync.
bare = key.split("/")[-1]
if bare != key:
disabled.discard(bare)
enabled.add(name)
disabled.discard(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
"Takes effect on next session."
)
@@ -780,129 +681,111 @@ def cmd_disable(name: str) -> None:
from rich.console import Console
console = Console()
key = _resolve_plugin_key(name)
if key is None:
if not _plugin_exists(name):
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
sys.exit(1)
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if key not in enabled and key in disabled:
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
if name not in enabled and name in disabled:
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
return
enabled.discard(key)
# Drop any legacy bare-name entry from the allow-list too, so a stale
# bare name can't keep a nested plugin loading after an explicit disable.
bare = key.split("/")[-1]
if bare != key:
enabled.discard(bare)
disabled.add(key)
enabled.discard(name)
disabled.add(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. "
f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
"Takes effect on next session."
)
def _plugin_exists(name: str) -> bool:
"""Return True if a plugin with *name* (bare name or key) exists."""
return _resolve_plugin_key(name) is not None
def _read_manifest_info(d: Path, prefix: str):
"""Read a plugin.yaml manifest and return (name, version, description, key).
Returns None if no manifest file exists.
"""
manifest_file = d / "plugin.yaml"
if not manifest_file.exists():
manifest_file = d / "plugin.yml"
if not manifest_file.exists():
return None
try:
import yaml
except ImportError:
yaml = None
name = d.name
version = ""
description = ""
if yaml:
try:
with open(manifest_file, encoding="utf-8") as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
key = f"{prefix}/{d.name}" if prefix else name
return name, version, description, key
def _scan_level(
base: Path,
source: str,
skip_names: set,
prefix: str,
depth: int,
seen: dict,
) -> None:
"""Recursive directory scan matching PluginManager._scan_directory_level.
Populates *seen* with key -> (name, version, description, source, dir, key).
"""
if not base.is_dir():
return
for d in sorted(base.iterdir()):
if not d.is_dir():
continue
if depth == 0 and skip_names and d.name in skip_names:
continue
info = _read_manifest_info(d, prefix)
if info is not None:
name, version, description, key = info
if key in seen and source == "bundled":
"""Return True if a plugin with *name* is installed (user) or bundled."""
# Installed: directory name or manifest name match in user plugins dir
user_dir = _plugins_dir()
if user_dir.is_dir():
if (user_dir / name).is_dir():
return True
for child in user_dir.iterdir():
if not child.is_dir():
continue
src_label = source
if source == "user" and (d / ".git").exists():
src_label = "git"
seen[key] = (name, version, description, src_label, d, key)
continue
if depth >= 1:
continue
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
_scan_level(d, source, set(), sub_prefix, depth + 1, seen)
manifest = _read_manifest(child)
if manifest.get("name") == name:
return True
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
from hermes_cli.plugins import get_bundled_plugins_dir
repo_plugins = get_bundled_plugins_dir()
if repo_plugins.is_dir():
candidate = repo_plugins / name
if candidate.is_dir() and (
(candidate / "plugin.yaml").exists()
or (candidate / "plugin.yml").exists()
):
return True
return False
def _discover_all_plugins() -> list:
"""Return a list of (name, version, description, source, dir_path, key) for
"""Return a list of (name, version, description, source, dir_path) for
every plugin the loader can see user + bundled + project.
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
bundled first, then user, then project; user overrides bundled on
key collision.
name collision.
"""
seen: dict = {} # key -> (name, version, description, source, path, key)
try:
import yaml
except ImportError:
yaml = None
seen: dict = {} # name -> (name, version, description, source, path)
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
from hermes_cli.plugins import get_bundled_plugins_dir
repo_plugins = get_bundled_plugins_dir()
for base, source, skip in (
(repo_plugins, "bundled", {"memory", "context_engine"}),
(_plugins_dir(), "user", set()),
):
_scan_level(base, source, skip, "", 0, seen)
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
if not base.is_dir():
continue
for d in sorted(base.iterdir()):
if not d.is_dir():
continue
if source == "bundled" and d.name in {"memory", "context_engine"}:
continue
manifest_file = d / "plugin.yaml"
if not manifest_file.exists():
manifest_file = d / "plugin.yml"
if not manifest_file.exists():
continue
name = d.name
version = ""
description = ""
if yaml:
try:
with open(manifest_file, encoding="utf-8") as f:
manifest = yaml.safe_load(f) or {}
name = manifest.get("name", d.name)
version = manifest.get("version", "")
description = manifest.get("description", "")
except Exception:
pass
# User plugins override bundled on name collision.
if name in seen and source == "bundled":
continue
src_label = source
if source == "user" and (d / ".git").exists():
src_label = "git"
seen[name] = (name, version, description, src_label, d)
return list(seen.values())
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
"""Return the user-facing activation state for a plugin name or key."""
if name in disabled or key in disabled:
def _plugin_status(name: str, enabled: set, disabled: set) -> str:
"""Return the user-facing activation state for a plugin name."""
if name in disabled:
return "disabled"
if name in enabled or key in enabled:
if name in enabled:
return "enabled"
return "not enabled"
@@ -915,7 +798,7 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set
if getattr(args, "enabled", False):
filtered = [
entry for entry in filtered
if _plugin_status(entry[0], enabled, disabled, key=entry[5]) == "enabled"
if _plugin_status(entry[0], enabled, disabled) == "enabled"
]
return filtered
@@ -940,19 +823,19 @@ def cmd_list(args: Any | None = None) -> None:
payload = [
{
"name": name,
"status": _plugin_status(name, enabled, disabled, key=key),
"status": _plugin_status(name, enabled, disabled),
"version": str(version),
"description": description,
"source": source,
}
for name, version, description, source, _dir, key in entries
for name, version, description, source, _dir in entries
]
print(json.dumps(payload, indent=2))
return
if getattr(args, "plain", False):
for name, version, _description, source, _dir, key in entries:
status = _plugin_status(name, enabled, disabled, key=key)
for name, version, _description, source, _dir in entries:
status = _plugin_status(name, enabled, disabled)
print(f"{status:12} {source:8} {str(version):8} {name}")
return
@@ -967,8 +850,8 @@ def cmd_list(args: Any | None = None) -> None:
table.add_column("Description")
table.add_column("Source", style="dim")
for name, version, description, source, _dir, key in entries:
status_name = _plugin_status(name, enabled, disabled, key=key)
for name, version, description, source, _dir in entries:
status_name = _plugin_status(name, enabled, disabled)
if status_name == "disabled":
status = "[red]disabled[/red]"
elif status_name == "enabled":
@@ -1168,14 +1051,14 @@ def cmd_toggle() -> None:
plugin_labels = []
plugin_selected = set()
for i, (name, _version, description, source, _d, key) in enumerate(entries):
for i, (name, _version, description, source, _d) in enumerate(entries):
label = f"{name} \u2014 {description}" if description else name
if source == "bundled":
label = f"{label} [bundled]"
plugin_names.append(name)
plugin_labels.append(label)
# Selected (enabled) when in enabled-set AND not in disabled-set
if (name in enabled_set or key in enabled_set) and name not in disabled_set and key not in disabled_set:
if name in enabled_set and name not in disabled_set:
plugin_selected.add(i)
# -- Provider categories --
@@ -1539,7 +1422,7 @@ def dashboard_install_plugin(
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
try:
git_url, _subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
if git_url.startswith(("http://", "file://")):
warnings.append(
"Insecure URL scheme; prefer https:// or git@ for production installs.",
@@ -1758,7 +1641,7 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
plugins_dir = _plugins_dir()
for n, _ver, _d, src, _path, _key in _discover_all_plugins():
for n, _ver, _d, src, _path in _discover_all_plugins():
if n == name and src == "bundled":
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
-5
View File
@@ -1010,7 +1010,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {wrapper_path}")
# 4. Remove profile directory
remove_error: Exception | None = None
try:
def _make_writable(func, path, exc):
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
@@ -1057,7 +1056,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {profile_dir}")
except Exception as e:
print(f"⚠ Could not remove {profile_dir}: {e}")
remove_error = e
# 5. Clear active_profile if it pointed to this profile
try:
@@ -1068,9 +1066,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
except Exception:
pass
if remove_error is not None:
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
print(f"\nProfile '{canon}' deleted.")
return profile_dir
+1 -4
View File
@@ -492,10 +492,7 @@ def get_label(provider_id: str) -> str:
def is_aggregator(provider: str) -> bool:
"""Return True when the provider is a multi-model aggregator."""
provider_norm = normalize_provider(provider or "")
if provider_norm.startswith("custom:"):
return True
pdef = get_provider(provider_norm)
pdef = get_provider(provider)
return pdef.is_aggregator if pdef else False
+2 -12
View File
@@ -250,23 +250,13 @@ class PtyBridge:
return
self._closed = True
try:
pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
except Exception:
pgid = None
# SIGHUP is the conventional "your terminal went away" signal.
# Send it to the whole foreground process group, not just the PTY
# leader: the dashboard TUI starts helper children such as the Python
# slash worker, and killing only the leader can strand those helpers.
# We escalate if the child ignores it.
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
if not self._proc.isalive():
break
try:
if pgid is not None:
os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
else:
self._proc.kill(sig)
self._proc.kill(sig)
except Exception:
pass
deadline = time.monotonic() + 0.5
+23 -1
View File
@@ -80,6 +80,7 @@ CONFIGURABLE_TOOLSETS = [
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
("apify", "🎭 Apify Actors", "discover, start, and collect Actor runs (requires APIFY_API_TOKEN)"),
]
@@ -112,7 +113,7 @@ def gui_toolset_label(label: str) -> str:
# `hermes tools` → X (Twitter) Search setup walks users through credential
# setup. The tool's check_fn means the schema still won't appear to the
# model if the credential later goes missing or expires.
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"}
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "apify"}
def _xai_credentials_present() -> bool:
@@ -562,6 +563,27 @@ TOOL_CATEGORIES = {
},
],
},
"apify": {
"name": "Apify Actors",
"icon": "🕷️",
"providers": [
{
"name": "Apify",
"badge": "paid",
"tag": (
"Run any Actor from the Apify Store — social media, "
"Google Maps, e-commerce, and more."
),
"env_vars": [
{
"key": "APIFY_API_TOKEN",
"prompt": "Apify API token",
"url": "https://apify.com/account/integrations",
},
],
},
],
},
}
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
+16 -102
View File
@@ -1405,54 +1405,6 @@ async def update_hermes():
}
def _recent_upstream_commits(n: int = 20) -> List[Dict[str, Any]]:
"""Commits the local checkout is behind ``origin/main`` by, newest first.
Logs the SAME range the behind-count uses (``HEAD..origin/main`` see
``banner._check_via_local_git``), NOT the branch's ``@{upstream}``. On a
feature-branch checkout ``@{upstream}`` is the branch's own tip (zero
commits), which would leave the changelog empty even though the count is
non-zero. Pinning to ``origin/main`` keeps count and changelog consistent.
Best-effort: returns [] if not a git checkout, origin/main is unreachable,
or git is unavailable. Never raises into the request path.
"""
try:
out = subprocess.run(
[
"git",
"-C",
str(PROJECT_ROOT),
"log",
"--format=%H%x1f%s%x1f%an%x1f%ct",
"HEAD..origin/main",
f"-n{int(n)}",
],
capture_output=True,
text=True,
timeout=5,
)
if out.returncode != 0:
return []
rows: List[Dict[str, Any]] = []
for line in out.stdout.splitlines():
if not line.strip():
continue
parts = (line.split("\x1f") + ["", "", "", "0"])[:4]
sha, summary, author, at = parts
rows.append(
{
"sha": sha[:7],
"summary": summary,
"author": author,
"at": int(at or 0),
}
)
return rows
except Exception:
return []
@app.get("/api/hermes/update/check")
async def check_hermes_update(force: bool = False):
"""Report whether a Hermes update is available, without applying it.
@@ -1473,11 +1425,6 @@ async def check_hermes_update(force: bool = False):
user must update out-of-band
update_command: the recommended command for this install method
message: human-readable guidance for non-applyable methods
commits: for git/pip installs that are behind, a list of the commits
the local checkout is behind upstream by each
{sha, summary, author, at}. Absent/empty otherwise. The
desktop's remote update overlay renders this as "what's
changed". Additive: existing consumers ignore it.
"""
install_method = detect_install_method(PROJECT_ROOT)
update_command = recommended_update_command_for_method(install_method)
@@ -1520,11 +1467,6 @@ async def check_hermes_update(force: bool = False):
payload["message"] = "You're on the latest version."
else:
payload["update_available"] = True
# Enrich with the actual commits we're behind by, so the desktop's
# remote update overlay can show "what's changed". git/pip only;
# best-effort (empty list on any failure).
if install_method in ("git", "pip"):
payload["commits"] = await asyncio.to_thread(_recent_upstream_commits)
return payload
@@ -8288,32 +8230,20 @@ async def get_models_analytics(days: int = 30):
# though uvicorn binds to 127.0.0.1.
# ---------------------------------------------------------------------------
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
# uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both
# expose the same public surface — spawn/read/write/resize/close/is_available —
# so the /api/pty WebSocket handler needs no platform guards.
if sys.platform.startswith("win"):
try:
from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError: # pragma: no cover - pywinpty missing
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
# Windows the import raises; catch and leave PtyBridge=None so the rest of
# the dashboard (sessions, jobs, metrics, config editor) still loads and the
# /api/pty endpoint cleanly refuses with a WSL-suggested message.
try:
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError as _pty_import_err: # pragma: no cover - Windows-only path
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub when win_pty_bridge cannot be imported."""
pass
else:
try:
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
_PTY_BRIDGE_AVAILABLE = True
except ImportError: # pragma: no cover - dev env without ptyprocess
PtyBridge = None # type: ignore[assignment]
_PTY_BRIDGE_AVAILABLE = False
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub on platforms where pty_bridge can't be imported."""
pass
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
"""Stub on platforms where pty_bridge can't be imported."""
pass
_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
_PTY_READ_CHUNK_TIMEOUT = 0.2
@@ -8573,11 +8503,6 @@ def _resolve_chat_argv(
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
env.setdefault("NODE_ENV", "production")
# Browser-embedded chat should prefer stable wheel-based scrollback over
# native terminal mouse tracking. When mouse tracking is enabled, wheel
@@ -9650,16 +9575,10 @@ def _merged_plugins_hub() -> Dict[str, Any]:
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
rows: List[Dict[str, Any]] = []
for name, version, description, source, dir_str, key in _discover_all_plugins():
# Both the path-derived key (nested category plugins) and the bare
# manifest name count for enabled/disabled state, matching the runtime
# loader's back-compat lookup.
aliases = {name}
if key:
aliases.add(key)
if aliases & disabled_set:
for name, version, description, source, dir_str in _discover_all_plugins():
if name in disabled_set:
runtime_status = "disabled"
elif aliases & enabled_set:
elif name in enabled_set:
runtime_status = "enabled"
else:
runtime_status = "inactive"
@@ -10159,9 +10078,4 @@ def start_server(
uvicorn.run(
app, host=host, port=port, log_level="warning",
proxy_headers=bool(app.state.auth_required),
# Detect half-open WS connections (reverse-proxy 524, dropped tunnels)
# within ~20-40s so WebSocketDisconnect fires the disconnect→reap path.
# 20s stays under Cloudflare Tunnel's idle timeout, keeping it warm.
ws_ping_interval=20.0,
ws_ping_timeout=20.0,
)
-179
View File
@@ -1,179 +0,0 @@
"""Windows ConPTY bridge for the `hermes dashboard` chat tab.
Drop-in counterpart to ``hermes_cli.pty_bridge.PtyBridge`` for native
Windows. Mirrors the exact public surface the ``/api/pty`` WebSocket
handler in ``hermes_cli.web_server`` consumes: ``spawn``, ``read``,
``write``, ``resize``, ``close``, ``is_available``, plus the
``PtyUnavailableError`` type.
Backed by ``pywinpty`` (already a declared win32 dependency in
pyproject.toml) instead of ``ptyprocess``/``fcntl``/``termios``, none of
which exist on native Windows. The read/write/terminate calls here match
the working winpty usage already shipping in ``tools/process_registry.py``.
"""
from __future__ import annotations
import os
import sys
import time
from typing import Optional, Sequence
try:
from winpty import PtyProcess # type: ignore
_PTY_AVAILABLE = sys.platform.startswith("win")
except ImportError: # pragma: no cover - non-Windows or pywinpty missing
PtyProcess = None # type: ignore
_PTY_AVAILABLE = False
__all__ = ["WinPtyBridge", "PtyUnavailableError"]
# Same clamp ceiling as the POSIX bridge: a broken winsize probe must never
# reach the resize call. ConPTY tolerates large values better than ioctl,
# but we keep parity to avoid layout surprises.
_MIN_DIMENSION = 1
_MAX_COLS = 2000
_MAX_ROWS = 1000
def _clamp(value: int, maximum: int) -> int:
try:
n = int(value)
except (TypeError, ValueError, OverflowError):
return _MIN_DIMENSION
if n < _MIN_DIMENSION:
return _MIN_DIMENSION
if n > maximum:
return maximum
return n
class PtyUnavailableError(RuntimeError):
"""Raised when a PTY cannot be created on this platform."""
class WinPtyBridge:
"""pywinpty-backed bridge with the same interface as ``PtyBridge``.
``web_server`` calls :meth:`read` inside ``run_in_executor``, so a
blocking/polling read here never stalls the event loop. ConPTY exposes
no selectable fd, so we poll with a short sleep instead of ``select``.
"""
def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined]
self._proc = proc
self._closed = False
# -- lifecycle --------------------------------------------------------
@classmethod
def is_available(cls) -> bool:
return bool(_PTY_AVAILABLE)
@classmethod
def spawn(
cls,
argv: Sequence[str],
*,
cwd: Optional[str] = None,
env: Optional[dict] = None,
cols: int = 80,
rows: int = 24,
) -> "WinPtyBridge":
if not _PTY_AVAILABLE:
if PtyProcess is None:
raise PtyUnavailableError(
"pywinpty is not installed. Install with: pip install pywinpty"
)
raise PtyUnavailableError("ConPTY is unavailable on this platform.")
spawn_env = (os.environ.copy() if env is None else dict(env))
if not spawn_env.get("TERM"):
spawn_env["TERM"] = "xterm-256color"
# pywinpty mirrors ptyprocess: dimensions=(rows, cols).
# This call shape is the one already used in tools/process_registry.py.
proc = PtyProcess.spawn( # type: ignore[union-attr]
list(argv),
cwd=cwd,
env=spawn_env,
dimensions=(rows, cols),
)
return cls(proc)
@property
def pid(self) -> int:
return int(self._proc.pid)
def is_alive(self) -> bool:
if self._closed:
return False
try:
return bool(self._proc.isalive())
except Exception:
return False
# -- I/O --------------------------------------------------------------
def read(self, timeout: float = 0.2) -> Optional[bytes]:
"""Up to 64 KiB of child output.
Returns bytes, ``b""`` when nothing is available this tick, or
``None`` once the child has exited (EOF).
"""
if self._closed:
return None
try:
data = self._proc.read(65536) # pywinpty returns str
except EOFError:
return None
except Exception:
return None
if not data:
# No fd to select on; poll politely so the executor thread
# doesn't pin a core while the TUI is idle.
time.sleep(min(timeout, 0.02))
return b""
if isinstance(data, bytes):
return data
# NOTE: pywinpty decodes internally, so a multibyte UTF-8 sequence
# can in theory split across reads. xterm.js tolerates the rare
# replacement char; this is the one fidelity tradeoff vs the POSIX
# raw-fd path.
return data.encode("utf-8", errors="replace")
def write(self, data: bytes) -> None:
if self._closed or not data:
return
try:
# The dashboard sends raw keystroke bytes; pywinpty.write wants text.
self._proc.write(data.decode("utf-8", errors="replace"))
except Exception:
return
def resize(self, cols: int, rows: int) -> None:
if self._closed:
return
cols = _clamp(cols, _MAX_COLS)
rows = _clamp(rows, _MAX_ROWS)
try:
self._proc.setwinsize(rows, cols) # pywinpty: (rows, cols)
except Exception:
pass
# -- teardown ---------------------------------------------------------
def close(self) -> None:
if self._closed:
return
self._closed = True
try:
self._proc.terminate(force=True)
except Exception:
pass
def __enter__(self) -> "WinPtyBridge":
return self
def __exit__(self, *_exc) -> None:
self.close()
-13
View File
@@ -253,14 +253,6 @@ _LEGACY_TOOLSET_MAP = {
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
# Hard cap on memoized get_tool_definitions() results. A long-lived Gateway
# process sees many distinct toolset/config fingerprints over its lifetime
# (per-session toolset sets, config edits, kanban-task toggles); without a
# bound the cache grows unboundedly. 8 comfortably covers the warm working
# set (the handful of distinct platform/toolset combos a gateway actually
# serves) while keeping the cap small. (#19251)
_TOOL_DEFS_CACHE_MAX = 8
def _clear_tool_defs_cache() -> None:
"""Drop memoized get_tool_definitions() results. Called when dynamic
@@ -337,11 +329,6 @@ def get_tool_definitions(
# agent inits and providers that enforce unique tool names
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
# Bound the cache with LRU eviction so a long-lived Gateway process
# doesn't accumulate entries unboundedly across the many distinct
# toolset/config fingerprints it sees over its lifetime (#19251).
if len(_tool_defs_cache) >= _TOOL_DEFS_CACHE_MAX:
_tool_defs_cache.pop(next(iter(_tool_defs_cache))) # evict oldest
_tool_defs_cache[cache_key] = result
return list(result)
return result
+21 -18
View File
@@ -73,24 +73,25 @@ in
patchPhase = ''
runHook prePatch
# Normalize trailing newlines on the root lockfile so source and
# npm-deps always match, regardless of what fetchNpmDeps preserves.
sed -i -z 's/\\n*$/\\n/' package-lock.json
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
# replacing its hardcoded /nix/store/.../diff with a wrapper that
# normalizes trailing newlines on both sides before comparing.
mkdir -p "$TMPDIR/bin"
cat > "$TMPDIR/bin/diff" << DIFFWRAP
#!/bin/sh
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
rm -f "\\$f1" "\\$f2"
exit \\$rc
DIFFWRAP
chmod +x "$TMPDIR/bin/diff"
export PATH="$TMPDIR/bin:$PATH"
# prefetch-npm-deps stores a *normalized* package-lock.json in the deps
# cache: newer npm writes advisory fields (engines/os/cpu/funding/bin/…)
# into lockfile entries, and prefetch strips the ones that don't affect
# which tarballs are fetched. npmConfigHook then does a byte-for-byte
# diff of the source lockfile against the cache's copy and fails on
# those purely-cosmetic differences — this is what breaks cold builds
# on a nixpkgs whose prefetch-npm-deps strips fields the committed
# lockfile carries.
#
# Adopt the cache's own normalized lockfile as the source so the
# consistency check is trivially satisfied. The resolved dependency set
# (version/resolved/integrity/dependencies) is byte-identical either
# way — fetchNpmDeps derived the cache *from* this lockfile — so `npm
# ci` installs exactly the same tree; only advisory metadata is dropped.
# Genuine drift is still caught upstream: a changed lockfile that didn't
# get its npmDepsHash refreshed fails the fixed-output hash check before
# this phase ever runs.
cp --no-preserve=mode,ownership ${npmDeps}/package-lock.json package-lock.json
runHook postPatch
'';
@@ -152,12 +153,14 @@ in
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci if so.
# external edit). Runs npm ci + fix-lockfiles if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
+3 -12
View File
@@ -49,12 +49,6 @@
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
# config.yaml mode: group-writable (0660) when interactive users share this
# HERMES_HOME via addToSystemPackages, so they can save settings through the
# CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets
# (.env) stay 0640 regardless — see below.
configYamlMode = if cfg.addToSystemPackages then "0660" else "0640";
# Generate .env from non-secret environment attrset
envFileContent = lib.concatStringsSep "\n" (
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
@@ -734,8 +728,7 @@
chmod 0750 ${cfg.stateDir}/home
# Create subdirs, set setgid + group-writable, migrate existing files.
# Nix-managed .env/.managed stay 0640/0644; config.yaml uses
# configYamlMode (0660 under addToSystemPackages, else 0640).
# Nix-managed files (config.yaml, .env, .managed) stay 0640/0644.
find ${cfg.stateDir}/.hermes -maxdepth 1 \
\( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \
-exec chmod g+rw {} + 2>/dev/null || true
@@ -750,14 +743,12 @@
# Merge Nix settings into existing config.yaml.
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
# If configFile is user-provided (not generated), overwrite instead of merge.
# Mode is configYamlMode (0660 under addToSystemPackages so interactive
# hermes-group users can save settings via the CLI/TUI, else 0640).
${if cfg.configFile != null then ''
install -o ${cfg.user} -g ${cfg.group} -m ${configYamlMode} -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
'' else ''
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
chmod ${configYamlMode} ${cfg.stateDir}/.hermes/config.yaml
chmod 0640 ${cfg.stateDir}/.hermes/config.yaml
''}
# Managed mode marker (so interactive shells also detect NixOS management)
+41
View File
@@ -0,0 +1,41 @@
# Apify Actor Tools
Bundled plugin that brings [Apify](https://apify.com/) Actors into Hermes. Apify
hosts 20,000+ ready-made Actors for web automation and data extraction
(Instagram, YouTube, Google Maps, LinkedIn, Amazon, and more). The agent can
discover the right Actor, inspect its input schema, run it, and collect
structured results.
## Tools
| Tool | What it does |
|------|--------------|
| `apify_discover` | Search the Apify Store by keyword, or fetch a specific Actor's input schema + README by `actor_id`. |
| `apify_start` | Fire-and-forget batch Actor starts (up to 10 per call). Returns run refs immediately so the agent keeps reasoning while Actors run. |
| `apify_collect` | Poll run statuses and return completed dataset results, wrapped in `EXTERNAL_UNTRUSTED_CONTENT` markers. Supports `limit` / `may_have_more` pagination. |
## Setup
1. Create an Apify account and get a token at
<https://apify.com/account/integrations>.
2. Run `hermes tools`, open **Apify Actors**, enable the toolset, and paste your
token. (The token is stored in `~/.hermes/.env` as `APIFY_API_TOKEN` and is
never sent to the model.)
The `apify` toolset is **off by default**. The three tools register on startup
but stay invisible to the model until `APIFY_API_TOKEN` is set (runtime
`check_fn` gate).
## Architecture
This is a bundled `kind: backend` plugin (auto-loads, no opt-in), modeled on the
Spotify plugin. Tool registration goes through the plugin API
(`ctx.register_tool()`), so the Apify tools never enter `_HERMES_CORE_TOOLS`. The
`apify-client` SDK is installed on demand via `tools.lazy_deps` (`search.apify`)
the first time an Actor runs.
| File | Purpose |
|------|---------|
| `__init__.py` | `register(ctx)` — wires the three tools via `ctx.register_tool()`. |
| `tools.py` | Handlers + JSON schemas. |
| `client.py` | Lazy `apify-client` import, token validation, module-level cache. |
+72
View File
@@ -0,0 +1,72 @@
"""Apify Actor execution plugin — bundled, auto-loaded.
Registers three tools (``apify_discover``, ``apify_start``, ``apify_collect``)
into the ``apify`` toolset. Each tool is gated by ``_check_token()`` when the
user has not set ``APIFY_API_TOKEN`` the tools stay registered (so they appear
in ``hermes tools``) but the runtime check prevents dispatch.
Why a plugin instead of top-level ``tools/`` files?
- ``plugins/`` is where third-party service integrations live (see
``plugins/spotify/`` for the same pattern optional SaaS, token-gated,
default-off toolset). ``tools/`` is reserved for foundational capabilities
(terminal, read_file, web_search, etc.).
- Bundled + ``kind: backend`` auto-loads on startup just like the Spotify
plugin no user opt-in needed, no ``plugins.enabled`` config.
- Keeps the three Apify tools out of ``_HERMES_CORE_TOOLS`` in ``toolsets.py``;
the plugin loader registers them via ``ctx.register_tool()``.
The ``apify`` toolset is default-off (``_DEFAULT_OFF_TOOLSETS`` in
``hermes_cli/tools_config.py``) and the ``APIFY_API_TOKEN`` setup UX is wired
through ``hermes tools`` (``TOOL_CATEGORIES``) and ``OPTIONAL_ENV_VARS``.
"""
from __future__ import annotations
import json
from typing import Any, Dict
from plugins.apify.tools import (
_COLLECT_SCHEMA,
_DISCOVER_SCHEMA,
_START_SCHEMA,
_check_token,
_collect_handler,
_discover_handler,
_start_handler,
)
async def _collect_handler_str(args: Dict[str, Any], **_kw: Any) -> str:
return json.dumps(await _collect_handler(args), default=str)
def register(ctx) -> None:
"""Register the Apify Actor tools. Called once by the plugin loader."""
ctx.register_tool(
name="apify_discover",
toolset="apify",
schema=_DISCOVER_SCHEMA,
handler=lambda args, **kw: json.dumps(_discover_handler(args), default=str),
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
emoji="🔍",
)
ctx.register_tool(
name="apify_start",
toolset="apify",
schema=_START_SCHEMA,
handler=lambda args, **kw: json.dumps(_start_handler(args), default=str),
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
emoji="▶️",
)
ctx.register_tool(
name="apify_collect",
toolset="apify",
schema=_COLLECT_SCHEMA,
handler=_collect_handler_str,
check_fn=_check_token,
requires_env=["APIFY_API_TOKEN"],
is_async=True,
emoji="📦",
)
+65
View File
@@ -0,0 +1,65 @@
"""Shared Apify SDK client — lazy import, token validation, and cache.
Used by the Apify Actor execution tools (plugins/apify/tools.py). The
``apify-client`` SDK is installed on demand via ``tools.lazy_deps`` so the
dependency is only pulled when the user actually enables the plugin and runs
an Actor.
"""
from __future__ import annotations
import os
from typing import Any, Optional
# Sent with every request so Apify can attribute traffic to this integration.
_HERMES_HEADERS = {"x-apify-integration-platform": "hermes-agent"}
_CLIENT_CLS: Optional[type] = None
_CLIENT: Optional[Any] = None
_CLIENT_CONFIG: Optional[Any] = None
def _load_client_cls() -> type:
global _CLIENT_CLS
if _CLIENT_CLS is None:
try:
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("search.apify", prompt=False)
except ImportError:
pass
except Exception as exc: # noqa: BLE001
raise ImportError(str(exc))
from apify_client import ApifyClient
_CLIENT_CLS = ApifyClient
return _CLIENT_CLS
def check_apify_api_key() -> bool:
"""Return True when APIFY_API_TOKEN is configured."""
return bool(os.getenv("APIFY_API_TOKEN", "").strip())
def get_apify_client() -> Any:
"""Return a cached ApifyClient built from APIFY_API_TOKEN.
Raises ValueError when the token is not set.
"""
global _CLIENT, _CLIENT_CONFIG
api_token = os.getenv("APIFY_API_TOKEN", "").strip()
if not api_token:
raise ValueError(
"Apify tools are not configured. "
"Set APIFY_API_TOKEN (get one at https://apify.com/account/integrations)."
)
client_config = ("direct", api_token)
if _CLIENT is not None and _CLIENT_CONFIG == client_config:
return _CLIENT
_CLIENT = _load_client_cls()(token=api_token, headers=_HERMES_HEADERS)
_CLIENT_CONFIG = client_config
return _CLIENT
def _reset_client_for_tests() -> None:
"""Drop cached client so tests can re-instantiate cleanly."""
global _CLIENT, _CLIENT_CONFIG
_CLIENT = None
_CLIENT_CONFIG = None
+9
View File
@@ -0,0 +1,9 @@
name: apify
version: 1.0.0
description: "Apify Actor execution — 3 tools (discover, start, collect) for running any of 20,000+ Actors from the Apify Store (web automation, data extraction, social media, maps, e-commerce). Gated on APIFY_API_TOKEN. Toolset is default-off; enable via `hermes tools` → Apify Actors."
author: JanHranicky
kind: backend
provides_tools:
- apify_discover
- apify_start
- apify_collect
+390
View File
@@ -0,0 +1,390 @@
"""Apify Actor execution tools — discover, start, collect.
Handlers and schemas for the three Apify tools. Registration happens in
``plugins/apify/__init__.py`` via ``ctx.register_tool()`` (the plugin API),
not via direct ``registry.register()`` calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}
_MAX_BATCH_RUNS = 10
_COLLECT_DEFAULT_LIMIT = 100
def _attr(obj: Any, key: str, default: Any = None) -> Any:
"""Get attribute or dict key from SDK response objects (apify_client returns either)."""
if isinstance(obj, dict):
return obj.get(key, default)
if obj is not None and hasattr(obj, key):
return getattr(obj, key)
return default
def _get_client() -> Any:
from plugins.apify.client import get_apify_client
return get_apify_client()
def _check_token() -> bool:
from plugins.apify.client import check_apify_api_key
return check_apify_api_key()
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def _discover_handler(args: Dict[str, Any]) -> Dict[str, Any]:
from tools.interrupt import is_interrupted
if is_interrupted():
return {"error": "Interrupted"}
query = (_attr(args, "query") or "").strip() or None
actor_id = (_attr(args, "actor_id") or "").strip() or None
if not query and not actor_id:
return {
"error": (
"Provide exactly one of 'query' (to search the Apify Store) "
"or 'actor_id' (to fetch an Actor's input schema)."
)
}
client = _get_client()
if actor_id:
try:
actor_info = client.actor(actor_id).get()
if actor_info is None:
return {
"error": (
f"Actor '{actor_id}' not found. "
"Check the ID format: username~actor-name."
)
}
input_schema: Any = None
readme: Any = None
build_detail = client.actor(actor_id).default_build().get()
if build_detail is not None:
actor_def = _attr(build_detail, "actorDefinition") or {}
raw_schema = _attr(actor_def, "input")
if raw_schema:
input_schema = json.dumps(raw_schema)
else:
fallback = _attr(build_detail, "inputSchema")
if fallback:
input_schema = str(fallback)
raw_readme = _attr(actor_def, "readme") or _attr(build_detail, "readme")
if raw_readme:
readme = str(raw_readme)[:3000]
username = _attr(actor_info, "username", "")
name = _attr(actor_info, "name", "")
title = _attr(actor_info, "title", "") or name
return {
"actor_id": f"{username}~{name}",
"name": name,
"title": title,
"username": username,
"description": _attr(actor_info, "description", ""),
"input_schema": input_schema,
"readme": readme,
"tip": (
f"Use apify_start with actor_id='{username}~{name}' "
"and an input matching the input_schema above."
),
}
except Exception as exc: # noqa: BLE001
logger.warning("apify_discover schema fetch error for %s: %s", actor_id, exc)
return {"error": str(exc)}
# Store search
try:
result = client.store().list(search=query, limit=10, sort_by="relevance")
items = _attr(result, "items") or []
actors: List[Dict[str, Any]] = []
for item in items:
stats = _attr(item, "stats") or {}
name = _attr(item, "name", "")
username = _attr(item, "username", "")
title = _attr(item, "title") or name
desc = (_attr(item, "description") or "")[:200]
run_count = _attr(stats, "totalRuns", 0) or 0
rating = _attr(stats, "averageRating")
actors.append({
"actor_id": f"{username}~{name}",
"name": name,
"title": title,
"username": username,
"description": desc,
"run_count": run_count,
"rating": rating,
})
return {"actors": actors}
except Exception as exc: # noqa: BLE001
logger.warning("apify_discover store search error for '%s': %s", query, exc)
return {"error": str(exc)}
def _start_handler(args: Dict[str, Any]) -> Dict[str, Any]:
from tools.interrupt import is_interrupted
if is_interrupted():
return {"error": "Interrupted"}
run_specs = args.get("runs") or []
if not run_specs:
return {"error": "Provide at least one run spec in 'runs'."}
if len(run_specs) > _MAX_BATCH_RUNS:
return {"error": f"Batch too large: {len(run_specs)} runs requested, maximum is {_MAX_BATCH_RUNS}."}
client = _get_client()
started: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
for spec in run_specs:
from tools.interrupt import is_interrupted
if is_interrupted():
break
actor_id = (spec.get("actor_id") or "").strip()
run_input = spec.get("input") or {}
label = spec.get("label")
if not actor_id:
errors.append({"error": "Missing 'actor_id' in run spec."})
continue
try:
run = client.actor(actor_id).start(run_input=run_input)
entry: Dict[str, Any] = {
"run_id": _attr(run, "id"),
"actor_id": actor_id,
"dataset_id": _attr(run, "default_dataset_id"),
"status": _attr(run, "status"),
}
if label:
entry["label"] = label
started.append(entry)
except Exception as exc: # noqa: BLE001
logger.warning("apify_start error for %s: %s", actor_id, exc)
err: Dict[str, Any] = {"actor_id": actor_id, "error": str(exc)}
if label:
err["label"] = label
errors.append(err)
result: Dict[str, Any] = {"runs": started}
if errors:
result["errors"] = errors
return result
async def _collect_handler(args: Dict[str, Any]) -> Dict[str, Any]:
from tools.interrupt import is_interrupted
if is_interrupted():
return {"error": "Interrupted"}
run_refs = args.get("runs") or []
if not run_refs:
return {"error": "Provide 'runs' array (from apify_start)."}
limit = int(args.get("limit") or _COLLECT_DEFAULT_LIMIT)
client = _get_client()
async def _check_run(ref: Dict[str, Any]) -> Dict[str, Any]:
run_id = ref.get("run_id", "")
actor_id = ref.get("actor_id", "")
dataset_id = ref.get("dataset_id", "")
label = ref.get("label")
base: Dict[str, Any] = {
"run_id": run_id,
"actor_id": actor_id,
"dataset_id": dataset_id,
}
if label:
base["label"] = label
try:
run_info = await asyncio.to_thread(client.run(run_id).get)
if run_info is None:
return {**base, "_type": "error", "error": "Run not found."}
status = _attr(run_info, "status", "UNKNOWN")
base["status"] = status
if status not in _TERMINAL_STATUSES:
return {**base, "_type": "pending"}
if status != "SUCCEEDED":
return {**base, "_type": "error", "error": f"Run ended with status: {status}"}
# SUCCEEDED — fetch dataset and wrap as external content
dataset_result = await asyncio.to_thread(
client.dataset(dataset_id).list_items, limit=limit
)
items = list(_attr(dataset_result, "items") or [])
may_have_more = len(items) == limit
if may_have_more:
logger.warning(
"apify_collect run %s: fetched %d items (hit limit=%d) — "
"dataset may have more; re-call with a higher limit if needed",
run_id, len(items), limit,
)
raw = json.dumps(items, indent=2, default=str)
if len(raw) > 50_000:
raw = raw[:50_000] + "\n\n[…truncated]"
wrapped = (
"<<<EXTERNAL_UNTRUSTED_CONTENT>>>\n"
+ raw
+ "\n<<<END_EXTERNAL_UNTRUSTED_CONTENT>>>"
)
result: Dict[str, Any] = {
**base,
"_type": "completed",
"result_count": len(items),
"data": wrapped,
}
if may_have_more:
result["may_have_more"] = True
return result
except Exception as exc: # noqa: BLE001
logger.warning("apify_collect error for run %s: %s", run_id, exc)
return {**base, "_type": "error", "error": str(exc)}
raw_results = await asyncio.gather(*[_check_run(ref) for ref in run_refs])
completed: List[Dict[str, Any]] = []
pending: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
for r in raw_results:
t = r.pop("_type", "error")
if t == "pending":
pending.append(r)
elif t == "error":
errors.append(r)
else:
completed.append(r)
return {
"all_done": len(pending) == 0,
"completed": completed,
"pending": pending,
"errors": errors,
}
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
_DISCOVER_SCHEMA: Dict[str, Any] = {
"name": "apify_discover",
"description": (
"Search the Apify Store for Actors by keyword, or fetch an Actor's "
"input schema and README. Provide 'query' to search, or 'actor_id' "
"to inspect a specific Actor. Actor IDs use tilde: username~actor-name."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keywords to search the Apify Store (e.g. 'instagram scraper').",
},
"actor_id": {
"type": "string",
"description": (
"Actor ID to fetch its input schema and README "
"(e.g. 'apify~google-search-scraper')."
),
},
},
},
}
_START_SCHEMA: Dict[str, Any] = {
"name": "apify_start",
"description": (
"Start one or more Apify Actor runs. Returns run references immediately "
f"(fire-and-forget). Pass the returned run refs to apify_collect to get results. "
f"Maximum {_MAX_BATCH_RUNS} runs per call."
),
"parameters": {
"type": "object",
"properties": {
"runs": {
"type": "array",
"description": f"List of Actor runs to start (max {_MAX_BATCH_RUNS}).",
"maxItems": _MAX_BATCH_RUNS,
"items": {
"type": "object",
"properties": {
"actor_id": {
"type": "string",
"description": "Actor ID (username~actor-name).",
},
"input": {
"type": "object",
"description": "Actor input parameters.",
},
"label": {
"type": "string",
"description": "Optional label to identify this run in results.",
},
},
"required": ["actor_id"],
},
},
},
"required": ["runs"],
},
}
_COLLECT_SCHEMA: Dict[str, Any] = {
"name": "apify_collect",
"description": (
"Poll the status of Apify Actor runs started with apify_start. "
"Returns completed results, still-running refs, and errors. "
"Re-call with the same run refs until all_done is true."
),
"parameters": {
"type": "object",
"properties": {
"runs": {
"type": "array",
"description": "Run references returned by apify_start.",
"items": {
"type": "object",
"properties": {
"run_id": {"type": "string"},
"actor_id": {"type": "string"},
"dataset_id": {"type": "string"},
"label": {"type": "string"},
},
"required": ["run_id", "actor_id", "dataset_id"],
},
},
"limit": {
"type": "integer",
"description": (
f"Max dataset items to fetch per run (default {_COLLECT_DEFAULT_LIMIT}). "
"Increase if may_have_more is true in a previous response."
),
},
},
"required": ["runs"],
},
}
-4
View File
@@ -86,7 +86,6 @@ class AudioBridge:
["pactl", "unload-module", str(mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
except Exception:
# Best-effort teardown — never raise from here.
@@ -112,7 +111,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -137,7 +135,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as exc:
# Roll back the null-sink we just created so we don't leak it.
@@ -145,7 +142,6 @@ class AudioBridge:
["pactl", "unload-module", str(sink_mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
raise RuntimeError(
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
-1
View File
@@ -94,7 +94,6 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=effective_cwd, env=env,
stdin=subprocess.DEVNULL,
)
stdout = result.stdout.strip()
stderr = result.stderr.strip()

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