Merge remote-tracking branch 'origin/main' into feat/gateway-health-diagnostics-monitoring

# Conflicts:
#	uv.lock
This commit is contained in:
victor-kyriazakos 2026-07-29 15:37:14 +00:00
commit 1773752c8c
689 changed files with 71676 additions and 5912 deletions

View file

@ -88,6 +88,11 @@ jobs:
# ---------------------------------------------------------------------
- name: Install uv (for docker tests)
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
# raw.githubusercontent.com every job; transient fetch failures
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
version: "0.9.28"
- name: Set up Python 3.11 (for docker tests)
run: uv python install 3.11

View file

@ -52,6 +52,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pin the uv version: unpinned, setup-uv resolves "latest" by
# fetching a manifest from raw.githubusercontent.com on EVERY job —
# a transient fetch failure fails the whole job (2026-07-28 slice-5
# incident). Pinned, the binary downloads directly; no manifest hop.
version: "0.9.28"
enable-cache: true
cache-dependency-glob: |
pyproject.toml

View file

@ -40,6 +40,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
# raw.githubusercontent.com every job; transient fetch failures
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
version: "0.9.28"
- name: Install ruff + ty
uses: ./.github/actions/retry
@ -129,6 +134,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
# raw.githubusercontent.com every job; transient fetch failures
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
version: "0.9.28"
- name: Install ruff
uses: ./.github/actions/retry

View file

@ -74,6 +74,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pin the uv version: unpinned, setup-uv resolves "latest" by
# fetching a manifest from raw.githubusercontent.com on EVERY job —
# a transient fetch failure fails the whole job (2026-07-28 slice-5
# incident). Pinned, the binary downloads directly; no manifest hop.
version: "0.9.28"
# 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
@ -188,6 +193,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pin the uv version: unpinned, setup-uv resolves "latest" by
# fetching a manifest from raw.githubusercontent.com on EVERY job —
# a transient fetch failure fails the whole job (2026-07-28 slice-5
# incident). Pinned, the binary downloads directly; no manifest hop.
version: "0.9.28"
# 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

View file

@ -70,6 +70,11 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
with:
# Pinned: unpinned setup-uv fetches a 'latest' manifest from
# raw.githubusercontent.com every job; transient fetch failures
# fail the job (2026-07-28 incident). Keep in sync with tests.yml.
version: "0.9.28"
# `uv lock --check` re-resolves the project from pyproject.toml and
# compares the result to uv.lock, exiting non-zero if they disagree.

View file

@ -200,6 +200,22 @@ RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
done && \
npm cache clean --force
# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
# The photon plugin's Node sidecar needs its own node_modules
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
# `npm ci` on first connect would hit EROFS — bake the deps here instead
# (deterministic installs, NS-559). The patch script is copied alongside
# the manifests because package.json's postinstall runs it, which also
# means the spectrum-ts patch is applied at build time. Layer-cached:
# only re-runs when the sidecar manifests/patch change.
COPY plugins/platforms/photon/sidecar/package.json \
plugins/platforms/photon/sidecar/package-lock.json \
plugins/platforms/photon/sidecar/patch-spectrum-mixed-attachments.mjs \
plugins/platforms/photon/sidecar/
RUN cd plugins/platforms/photon/sidecar && \
npm ci --no-audit --fetch-retries=5 && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
# Copy only pyproject.toml + uv.lock so the Python dep resolve + wheel
# download + native-extension compile layer is cached unless those inputs

View file

@ -118,7 +118,7 @@ def _provider_default_routes(provider: str) -> set[str]:
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
overlay = HERMES_OVERLAYS.get(provider)
provider_def = get_provider(provider)
provider_def = get_provider(provider, allow_network=False)
for value in (
getattr(overlay, "base_url_override", ""),
getattr(provider_def, "base_url", ""),
@ -887,8 +887,10 @@ def init_agent(
# report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS.
agent._credits_state = None
agent._credits_session_start_micros = None
# Threshold-notice latch (L4): active sticky-notice keys + the warn90 crossing gate.
agent._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None}
# Threshold-notice latch (L4): active sticky-notice keys + the crossing gates.
from agent.credits_tracker import new_credits_latch
agent._credits_latch = new_credits_latch()
# OpenRouter response cache hit counter — incremented when
# X-OpenRouter-Cache-Status: HIT is seen in streaming response headers.
@ -2276,7 +2278,18 @@ def init_agent(
# AFTER the custom_providers branch so per-model overrides aren't lost.
agent._config_context_length = _config_context_length
agent._ensure_lmstudio_runtime_loaded(_config_context_length)
_lmstudio_runtime_context_length = agent._ensure_lmstudio_runtime_loaded(
_config_context_length
)
if agent._lmstudio_load_was_unverified(_lmstudio_runtime_context_length):
_ra().logger.warning(
"LM Studio model activation was rejected or completed without a "
"verifiable active context length; falling back to configured context"
)
_effective_context_length = agent._effective_lmstudio_context_length(
_config_context_length,
_lmstudio_runtime_context_length,
)
@ -2353,7 +2366,7 @@ def init_agent(
agent.model,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
config_context_length=_config_context_length,
config_context_length=_effective_context_length,
provider=agent.provider,
custom_providers=_custom_providers,
)
@ -2388,7 +2401,7 @@ def init_agent(
quiet_mode=agent.quiet_mode,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
config_context_length=_config_context_length,
config_context_length=_effective_context_length,
provider=agent.provider,
api_mode=agent.api_mode,
abort_on_summary_failure=compression_abort_on_summary_failure,
@ -2417,7 +2430,13 @@ def init_agent(
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
_ctx = getattr(agent.context_compressor, "context_length", 0)
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH:
_allow_lmstudio_explicit_below_floor = (
str(getattr(agent, "provider", "") or "").strip().lower() == "lmstudio"
and isinstance(agent._config_context_length, int)
and not isinstance(agent._config_context_length, bool)
and agent._config_context_length > 0
)
if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH and not _allow_lmstudio_explicit_below_floor:
raise ValueError(
f"Model {agent.model} has a context window of {_ctx:,} tokens, "
f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required "

View file

@ -2137,6 +2137,16 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent, "_credential_pool_entry_id", _MISSING
)
def _restore_snapshot() -> None:
for _name, _value in _snapshot.items():
if _value is _MISSING:
# Attribute did not exist before the swap — don't fabricate it.
continue
try:
setattr(agent, _name, _value)
except Exception: # noqa: BLE001
pass
try:
# Clear the per-config context_length override so the new model's
# actual context window is resolved via get_model_context_length()
@ -2305,16 +2315,42 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# caller's exception handler can surface a meaningful warning. The
# exception is re-raised; cli.py / gateway/run.py / tui_gateway catch
# it and print "Agent swap failed; change applied to next session".
for _name, _value in _snapshot.items():
if _value is _MISSING:
# Attribute did not exist before the swap — don't fabricate it.
continue
try:
setattr(agent, _name, _value)
except Exception: # noqa: BLE001
pass
_restore_snapshot()
raise
# ── LM Studio: preload before probing context length ──
_sm_custom_providers = None
try:
from hermes_cli.config import (
get_compatible_custom_providers,
get_custom_provider_context_length,
load_config,
)
_sm_cfg = load_config()
_sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
_destination_context_intent = get_custom_provider_context_length(
model=agent.model,
base_url=agent.base_url,
custom_providers=_sm_custom_providers,
)
except Exception:
_destination_context_intent = None
agent._config_context_length = _destination_context_intent
_runtime_context_length = agent._ensure_lmstudio_runtime_loaded(
_destination_context_intent
)
if agent._lmstudio_load_was_unverified(_runtime_context_length):
logger.warning(
"LM Studio model activation was rejected or completed without a "
"verifiable active context length during model switch; continuing "
"with configured context"
)
_effective_context_length = agent._effective_lmstudio_context_length(
_destination_context_intent,
_runtime_context_length,
)
# ── Re-evaluate prompt caching ──
agent._use_prompt_caching, agent._use_native_cache_layout = (
agent._anthropic_prompt_cache_policy(
@ -2325,22 +2361,15 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
)
)
# ── LM Studio: preload before probing context length ──
agent._ensure_lmstudio_runtime_loaded()
# ── Update context compressor ──
if hasattr(agent, "context_compressor") and agent.context_compressor:
from agent.model_metadata import get_model_context_length
# Re-read custom_providers from live config so per-model
# context_length overrides are honored when switching to a
# custom provider mid-session (closes #15779).
_sm_custom_providers = None
try:
from hermes_cli.config import load_config, get_compatible_custom_providers
_sm_cfg = load_config()
_sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
except Exception:
_sm_custom_providers = None
if _sm_custom_providers is None:
try:
from hermes_cli.config import get_compatible_custom_providers, load_config
_sm_custom_providers = get_compatible_custom_providers(load_config())
except Exception:
_sm_custom_providers = None
# ``agent.api_key`` may be a callable (Azure Foundry Entra ID
# token provider). ``get_model_context_length`` expects a
# string for its live-probe paths; for Foundry the context
@ -2352,7 +2381,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
base_url=agent.base_url,
api_key=_ctx_api_key,
provider=agent.provider,
config_context_length=getattr(agent, "_config_context_length", None),
config_context_length=_effective_context_length,
custom_providers=_sm_custom_providers,
)
agent.context_compressor.update_model(
@ -2475,7 +2504,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
tool_call_id: Optional[str] = None, messages: list = None,
pre_tool_block_checked: bool = False,
skip_tool_request_middleware: bool = False,
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None) -> str:
tool_request_middleware_trace: Optional[List[Dict[str, Any]]] = None,
skip_tool_execution_middleware: bool = False) -> str:
"""Invoke a single tool and return the result string. No display logic.
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
@ -2654,8 +2684,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args)
else:
def _execute(next_args: dict) -> Any:
return _ra().handle_function_call(
function_name, next_args, effective_task_id,
dispatch_kwargs = dict(
tool_call_id=tool_call_id,
session_id=agent.session_id or "",
turn_id=getattr(agent, "_current_turn_id", "") or "",
@ -2667,6 +2696,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
disabled_toolsets=getattr(agent, "disabled_toolsets", None),
tool_request_middleware_trace=list(_tool_middleware_trace),
)
if skip_tool_execution_middleware:
dispatch_kwargs["skip_tool_execution_middleware"] = True
return _ra().handle_function_call(
function_name,
next_args,
effective_task_id,
**dispatch_kwargs,
)
if skip_tool_execution_middleware:
return _execute(function_args)
from hermes_cli.middleware import run_tool_execution_middleware

View file

@ -42,6 +42,7 @@ Payment / credit exhaustion fallback:
import contextlib
import contextvars
import functools
import hashlib
import inspect
import json
@ -50,9 +51,10 @@ import os
import re
import threading
import time
import uuid
from pathlib import Path # noqa: F401 — used by test mocks
from types import SimpleNamespace
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
from urllib.parse import urlparse, parse_qs, urlunparse
# NOTE: `from openai import OpenAI` is deliberately NOT at module top — the
@ -534,7 +536,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str:
# plus providers we intentionally keep pinned here (e.g. Anthropic predates
# profiles). New providers should set default_aux_model on their profile instead.
_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"gemini": "gemini-3.6-flash",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
"stepfun": "step-3.5-flash",
@ -543,7 +545,7 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"anthropic": "claude-haiku-4-5-20251001",
"opencode-zen": "gemini-3-flash",
"opencode-go": "glm-5",
"kilocode": "google/gemini-3-flash-preview",
"kilocode": "google/gemini-3.6-flash",
"ollama-cloud": "nemotron-3-nano:30b",
"tencent-tokenhub": "hy3-preview",
# NB: no "deepinfra" entry — its aux model lives on the ProviderProfile
@ -758,8 +760,8 @@ NOUS_EXTRA_BODY = _nous_extra_body()
auxiliary_is_nous: bool = False
# Default auxiliary models per provider
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_NOUS_MODEL = "google/gemini-3-flash-preview"
_OPENROUTER_MODEL = "google/gemini-3.6-flash"
_NOUS_MODEL = "google/gemini-3.6-flash"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
@ -2492,6 +2494,167 @@ _RUNTIME_MAIN_AUTH_MODE: str = ""
_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
contextvars.ContextVar("auxiliary_runtime_main", default=None)
)
_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (
contextvars.ContextVar("auxiliary_relay_call", default=None)
)
def _relay_auxiliary_call(callback):
"""Give every physical retry in one auxiliary call a shared Relay identity."""
@functools.wraps(callback)
def wrapped(*args, **kwargs):
task = args[0] if args else kwargs.get("task")
token = _RELAY_AUX_CALL_CONTEXT.set({
"task": str(task or "unknown"),
"request_id": f"aux-{uuid.uuid4().hex}",
"attempt_count": 0,
"provider": "",
"model": "",
"api_mode": "chat_completions",
})
try:
return callback(*args, **kwargs)
except BaseException:
_fail_relay_auxiliary_call()
raise
finally:
_RELAY_AUX_CALL_CONTEXT.reset(token)
return wrapped
def _relay_auxiliary_call_async(callback):
"""Async counterpart to :func:`_relay_auxiliary_call`."""
@functools.wraps(callback)
async def wrapped(*args, **kwargs):
task = args[0] if args else kwargs.get("task")
token = _RELAY_AUX_CALL_CONTEXT.set({
"task": str(task or "unknown"),
"request_id": f"aux-{uuid.uuid4().hex}",
"attempt_count": 0,
"provider": "",
"model": "",
"api_mode": "chat_completions",
})
try:
return await callback(*args, **kwargs)
except BaseException:
_fail_relay_auxiliary_call()
raise
finally:
_RELAY_AUX_CALL_CONTEXT.reset(token)
return wrapped
def _set_relay_auxiliary_route(
provider: str | None,
model: str | None,
api_mode: str | None,
) -> None:
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return
context["provider"] = str(provider or "auxiliary")
context["model"] = str(model or "unknown")
context["api_mode"] = str(api_mode or "chat_completions")
def _relay_auxiliary_metadata(
*,
provider: str | None = None,
api_mode: str | None = None,
) -> tuple[str, str, dict[str, Any]] | None:
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return None
attempt_count = int(context.get("attempt_count") or 0)
context["attempt_count"] = attempt_count + 1
provider_name = str(provider or context.get("provider") or "auxiliary")
model_name = str(context.get("model") or "unknown")
return provider_name, model_name, {
"api_mode": str(api_mode or context.get("api_mode") or "chat_completions"),
"api_request_id": str(context["request_id"]),
"call_role": f"auxiliary:{context['task']}",
"retry_count": attempt_count,
"auxiliary_task": str(context["task"]),
}
def _relay_sync_completion(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
create: Callable[[dict[str, Any]], Any] | None = None,
) -> Any:
callback = create or (lambda request: client.chat.completions.create(**request))
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return callback(kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return relay_llm.execute_current(
kwargs,
callback,
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
metadata=metadata,
defer_logical_completion=True,
)
async def _relay_async_completion(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
create: Callable[[dict[str, Any]], Any] | None = None,
) -> Any:
callback = create or (lambda request: client.chat.completions.create(**request))
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return await callback(kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return await relay_llm.execute_current_async(
kwargs,
callback,
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
metadata=metadata,
defer_logical_completion=True,
)
def _relay_sync_stream(
client: Any,
kwargs: dict[str, Any],
*,
provider: str | None = None,
api_mode: str | None = None,
) -> Any:
route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode)
if route is None:
return client.chat.completions.create(**kwargs)
provider_name, fallback_model, metadata = route
from agent import relay_llm
return relay_llm.stream_current(
kwargs,
lambda request: client.chat.completions.create(**request),
name=provider_name,
model_name=str(kwargs.get("model") or fallback_model),
finalizer=dict,
metadata=metadata,
)
_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "")
_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()
@ -3801,7 +3964,13 @@ def _retry_same_provider_sync(
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task,
_relay_sync_completion(
retry_client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
),
task,
)
@ -3866,7 +4035,13 @@ async def _retry_same_provider_async(
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task,
await _relay_async_completion(
retry_client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
),
task,
)
@ -4071,7 +4246,7 @@ def _call_fallback_candidate_sync(
base_url=fb_base, task=task)
try:
return _validate_llm_response(
fb_client.chat.completions.create(**fb_kwargs), task)
_relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
@ -4088,7 +4263,13 @@ def _call_fallback_candidate_sync(
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task)
_relay_sync_completion(
retry_client,
retry_kwargs,
provider=fb_provider,
),
task,
)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
@ -4137,7 +4318,13 @@ async def _call_fallback_candidate_async(
base_url=fb_base, task=task)
try:
return _validate_llm_response(
await fb_client.chat.completions.create(**fb_kwargs), task)
await _relay_async_completion(
fb_client,
fb_kwargs,
provider=fb_label,
),
task,
)
except Exception as fb_err:
if not _is_auth_error(fb_err):
raise
@ -4155,7 +4342,13 @@ async def _call_fallback_candidate_async(
base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task)
try:
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
await _relay_async_completion(
retry_client,
retry_kwargs,
provider=fb_provider,
),
task,
)
except Exception as retry_err:
if not _is_auth_error(retry_err):
raise
@ -7270,6 +7463,7 @@ def _validate_llm_response(
except (AttributeError, TypeError, IndexError) as exc:
recovered = _recover_aux_response_message(response)
if recovered is not None:
_complete_relay_auxiliary_call()
return recovered
response_type = type(response).__name__
response_preview = str(response)[:120]
@ -7279,9 +7473,34 @@ def _validate_llm_response(
f"Expected object with .choices[0].message — check provider "
f"adapter or custom endpoint compatibility."
) from exc
_complete_relay_auxiliary_call()
return response
def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None:
"""Close one auxiliary logical call after acceptance or terminal failure."""
context = _RELAY_AUX_CALL_CONTEXT.get()
if context is None:
return
from agent import relay_llm
relay_llm.complete_logical_call(
str(context.get("request_id") or ""),
outcome=outcome,
)
def _fail_relay_auxiliary_call() -> None:
"""Close a terminally failed call without replacing its original error."""
try:
_complete_relay_auxiliary_call(outcome="failed")
except Exception:
logger.warning(
"Relay auxiliary failure finalization failed",
exc_info=True,
)
def _recover_aux_response_message(response: Any) -> Optional[Any]:
"""Synthesize chat-completions shape from Responses-style text fields.
@ -7680,6 +7899,7 @@ async def _acreate_with_stream(
)
@_relay_auxiliary_call
def call_llm(
task: str = None,
*,
@ -7820,6 +8040,11 @@ def call_llm(
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
_set_relay_auxiliary_route(
resolved_provider,
final_model,
resolved_api_mode,
)
# Log what we're about to do — makes auxiliary operations visible
_base_info = str(getattr(client, "base_url", resolved_base_url) or "")
@ -7857,7 +8082,12 @@ def call_llm(
kwargs["stream"] = True
if stream_options:
kwargs["stream_options"] = stream_options
return client.chat.completions.create(**kwargs)
return _relay_sync_stream(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
)
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
@ -7880,10 +8110,18 @@ def call_llm(
# for the transient retry every auxiliary task shares. (PR #16587)
try:
return _validate_llm_response(
_create_with_progress(
client, kwargs, task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=lambda request: _create_with_progress(
client,
request,
task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
),
),
),
task,
@ -7919,10 +8157,19 @@ def call_llm(
time.sleep(_backoff)
try:
return _validate_llm_response(
_create_with_progress(
client, kwargs, task,
force_stream=_provider_requires_stream(
resolved_provider, _base_info or resolved_base_url,
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=lambda request: _create_with_progress(
client,
request,
task,
force_stream=_provider_requires_stream(
resolved_provider,
_base_info or resolved_base_url,
),
),
),
task)
@ -7942,7 +8189,12 @@ def call_llm(
)
try:
return _validate_llm_response(
client.chat.completions.create(**retry_kwargs), task)
_relay_sync_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
retry_err_str = str(retry_err)
# If retry still fails, fall through to the max_tokens /
@ -7980,7 +8232,12 @@ def call_llm(
kwargs.pop("max_completion_tokens", None)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
@ -8010,7 +8267,12 @@ def call_llm(
kwargs["model"] = healed_model
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
first_err = retry_err
@ -8043,7 +8305,12 @@ def call_llm(
kwargs["model"] = refreshed_model
try:
return _validate_llm_response(
refreshed_client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (
_is_auth_error(retry_err)
@ -8071,7 +8338,12 @@ def call_llm(
if refreshed_model and refreshed_model != kwargs.get("model"):
kwargs["model"] = refreshed_model
return _validate_llm_response(
refreshed_client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
# ── Auth refresh retry ───────────────────────────────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
@ -8121,7 +8393,12 @@ def call_llm(
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
_relay_sync_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
@ -8379,6 +8656,7 @@ def extract_content_or_reasoning(response) -> str:
return ""
@_relay_auxiliary_call_async
async def async_call_llm(
task: str = None,
*,
@ -8470,6 +8748,11 @@ async def async_call_llm(
f"Run: hermes setup")
effective_timeout = _effective_aux_timeout(task, timeout)
_set_relay_auxiliary_route(
resolved_provider,
final_model,
resolved_api_mode,
)
# Pass the client's actual base_url (not just resolved_base_url) so
# endpoint-specific temperature overrides can distinguish
@ -8508,7 +8791,14 @@ async def async_call_llm(
try:
return _validate_llm_response(
await _acreate(kwargs), task,
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=_acreate,
),
task,
provider=resolved_provider, base_url=_client_base)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
@ -8529,7 +8819,14 @@ async def async_call_llm(
task or "call", transient_err,
)
return _validate_llm_response(
await _acreate(kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
create=_acreate,
),
task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
@ -8540,7 +8837,12 @@ async def async_call_llm(
)
try:
return _validate_llm_response(
await client.chat.completions.create(**retry_kwargs), task)
await _relay_async_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
retry_err_str = str(retry_err)
if not (
@ -8574,7 +8876,12 @@ async def async_call_llm(
kwargs.pop("max_completion_tokens", None)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
@ -8603,7 +8910,12 @@ async def async_call_llm(
kwargs["model"] = healed_model
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
first_err = retry_err
@ -8635,7 +8947,12 @@ async def async_call_llm(
kwargs["model"] = refreshed_model
try:
return _validate_llm_response(
await refreshed_client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (
_is_auth_error(retry_err)
@ -8662,7 +8979,12 @@ async def async_call_llm(
if refreshed_model and refreshed_model != kwargs.get("model"):
kwargs["model"] = refreshed_model
return _validate_llm_response(
await refreshed_client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
refreshed_client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
# ── Auth refresh retry (mirrors sync call_llm) ───────────────
auth_refresh_provider = _auth_refresh_provider_for_route(
@ -8706,7 +9028,12 @@ async def async_call_llm(
if _is_rate_limit_error(first_err) and not _is_payment_error(first_err):
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
await _relay_async_completion(
client,
kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)):
raise

File diff suppressed because it is too large Load diff

View file

@ -191,6 +191,28 @@ def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
return f"call_{digest}"
def _clamp_responses_call_id(call_id: str) -> str:
"""Keep a ``call_id`` within the Responses API's 64-char limit (#73492).
The codex app-server namespaces MCP tool call ids as
``codex_mcp__<server>__<tool>_<codex_call_id>``; with an ``exec-<uuid>``
component the built-in ``hermes-tools`` server already overflows 64 chars,
and the Responses API rejects the whole payload with a non-retryable HTTP
400 that then replays every turn permanently bricking the session.
Sibling defect to #10788 (which clamped ``input[*].id``), applied here to
``call_id``. The surrogate is a pure, deterministic function of the
original, so the ``function_call`` and its matching ``function_call_output``
which carry the same original id map to the same surrogate and stay
paired without correlating the two items. Short ids pass through unchanged,
preserving prompt-cache prefixes.
"""
if len(call_id) <= _MAX_RESPONSES_ITEM_ID_LENGTH:
return call_id
digest = hashlib.sha256(call_id.encode("utf-8", errors="replace")).hexdigest()[:32]
return f"call_{digest}"
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
"""Split a stored tool id into (call_id, response_item_id)."""
if not isinstance(raw_id, str):
@ -546,7 +568,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"name": fn_name,
"arguments": arguments,
})
@ -589,7 +611,7 @@ def _chat_messages_to_responses_input(
items.append({
"type": "function_call_output",
"call_id": call_id,
"call_id": _clamp_responses_call_id(call_id),
"output": output_value,
})

View file

@ -74,7 +74,10 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
# Enqueued for the SessionDB background writer — keeps the
# per-call accounting write off the turn thread (see
# conversation_loop's queue_token_counts call).
agent._session_db.queue_token_counts(
agent.session_id,
model=agent.model,
billing_provider=agent.provider,
@ -154,7 +157,8 @@ def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
# Enqueued for the SessionDB background writer (see above).
agent._session_db.queue_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
@ -1236,6 +1240,8 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
"""
import httpx as _httpx
from agent import relay_llm
active_client = client or agent._ensure_primary_openai_client(reason="codex_stream_direct")
max_stream_retries = 1
# Accumulate streamed text so callers / compat shims can read it.
@ -1260,48 +1266,88 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted before Codex stream retry")
stream_kwargs = dict(api_kwargs)
stream_kwargs["stream"] = True
intercepted_events = []
writer_token = {"value": None}
def _open_codex_stream(next_api_kwargs: dict[str, Any]):
stream_kwargs = dict(next_api_kwargs)
stream_kwargs["stream"] = True
return active_client.responses.create(**stream_kwargs)
def _codex_stream_created(_raw_stream: Any) -> None:
# Claim the delta sink for THIS physical attempt. A newer attempt
# supersedes this token and fences late deltas out of the turn.
writer_token["value"] = claim_stream_writer(agent)
def _accept_codex_chunk(_chunk: Any) -> bool:
token = writer_token["value"]
if token is None or stream_writer_is_current(agent, token):
return True
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return False
def _finalize_codex_stream() -> Any:
return _consume_codex_event_stream(
list(intercepted_events),
model=api_kwargs.get("model"),
)
try:
event_stream = active_client.responses.create(**stream_kwargs)
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
event_stream = relay_llm.stream(
dict(api_kwargs),
_open_codex_stream,
session_id=str(getattr(agent, "session_id", "") or ""),
name=str(getattr(agent, "provider", "") or "codex"),
model_name=str(api_kwargs.get("model") or ""),
finalizer=_finalize_codex_stream,
on_stream_created=_codex_stream_created,
on_chunk=intercepted_events.append,
chunk_adapter=lambda chunk: chunk,
accept_chunk=_accept_codex_chunk,
completed_response_predicate=lambda response: bool(
hasattr(response, "output") and not hasattr(response, "__iter__")
),
metadata={
"api_mode": "codex_responses",
"api_request_id": getattr(agent, "_current_api_request_id", None),
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
"retry_count": attempt,
},
defer_logical_completion=True,
)
except (
_httpx.RemoteProtocolError,
_httpx.ReadTimeout,
_httpx.ConnectError,
ConnectionError,
) as exc:
if attempt < max_stream_retries:
logger.debug(
"Codex Responses stream connect failed (attempt %s/%s); retrying. %s error=%s",
attempt + 1, max_stream_retries + 1,
agent._client_log_context(), exc,
"Codex Responses stream connect failed (attempt %s/%s); "
"retrying. %s error=%s",
attempt + 1,
max_stream_retries + 1,
agent._client_log_context(),
exc,
)
continue
raise
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions/anthropic/bedrock paths. If a prior attempt's
# stream is somehow still alive, this claim supersedes it so its
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = claim_stream_writer(agent)
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not stream_writer_is_current(agent, _tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
"invariant (model=%s).",
api_kwargs.get("model", "unknown"),
)
return True
return False
def _interrupt_or_superseded() -> bool:
return bool(agent._interrupt_requested)
try:
# Compatibility: some mocks/providers return a concrete response
# instead of an iterable. Pass it straight through.
if hasattr(event_stream, "output") and not hasattr(event_stream, "__iter__"):
return event_stream
try:
final = _consume_codex_event_stream(
event_stream,
@ -1320,6 +1366,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
on_event=_on_event,
interrupt_check=_interrupt_or_superseded,
)
# The terminal SSE frame is contractually last. Request the
# end-of-stream marker so Relay can run its response finalizer
# and close the physical attempt scope before Hermes returns.
if not agent._interrupt_requested:
for _ignored in event_stream:
pass
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
if attempt < max_stream_retries:
logger.debug(
@ -1330,6 +1382,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
)
continue
raise
except RuntimeError:
if event_stream.final_response is not None:
return event_stream.final_response
raise
if final.status in {"incomplete", "failed"}:
logger.warning(
@ -1347,7 +1403,20 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
try:
close_fn()
except Exception:
pass
# A failed close can leave this response's connection
# checked out of the httpx pool while the caller's finally
# reports a reuse-reason close (e.g. interrupt_check broke
# the event loop with collected output) — caching the
# client with the leaked connection. Poison the slot so
# that close really closes the pool (owner-thread abort;
# mirrors the chat-streaming interrupt-break handling).
# ``client is None`` means the shared primary client,
# which is never reuse-cached and must not have its
# sockets force-shut here.
if client is not None:
agent._abort_request_openai_client(
active_client, reason="codex_stream_close_failed"
)
def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):

View file

@ -1354,6 +1354,125 @@ class ContextCompressor(ContextEngine):
previous = telemetry.get("aux_call_duration_ms") or 0
telemetry["aux_call_duration_ms"] = previous + max(0, int(duration_ms))
def _emit_init_summary_once(self) -> None:
"""Emit the informative startup line once, on first resolution.
Deferred out of ``__init__`` (#32221): the line reports resolved token
budgets, so emitting it there would force the synchronous
``get_model_context_length()`` probe during construction. Reads via
the properties below are safe here because
``_resolved_context_length`` is already set.
"""
if not getattr(self, "_log_init_summary", False):
return
self._log_init_summary = False
logger.info(
"Context compressor initialized: model=%s context_length=%d "
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
"provider=%s base_url=%s",
self.model, self._resolved_context_length, self.threshold_tokens,
self.threshold_percent * 100, self.summary_target_ratio * 100,
self.tail_token_budget,
self.provider or "none", self.base_url or "none",
)
def _resolve_context_length(self) -> int:
"""Resolve and cache the model's context length on first access."""
if self._resolved_context_length is None:
self._resolved_context_length = get_model_context_length(
self.model,
base_url=self.base_url,
api_key=self.api_key,
config_context_length=self._config_context_length,
provider=self.provider,
)
# Small-context threshold floor: models under 512K trigger at
# >=75% so compaction doesn't fire with half the window still
# free. Raise-only; must run AFTER context_length is resolved
# and BEFORE threshold_tokens is derived (deferred here from
# __init__ along with the resolution itself, #32221).
# _base_threshold_percent already has the per-model override
# applied, so the floor stacks on top of it.
self.threshold_percent = self._effective_threshold_percent(
self._resolved_context_length, self._base_threshold_percent,
)
self._emit_init_summary_once()
return self._resolved_context_length
@property
def context_length(self) -> int:
return self._resolve_context_length()
@context_length.setter
def context_length(self, value: int) -> None:
# No-op guard: repeated assignment of the SAME window (e.g. the codex
# app-server usage callback re-reports the window on every response)
# must not invalidate the derived budgets — that would wipe runtime
# corrections applied directly to threshold_tokens/tail_token_budget
# (see conversation_compression's aux-context threshold sync), which
# persisted on main's eager-init behavior.
if value == getattr(self, "_resolved_context_length", None):
return
self._resolved_context_length = value
# Re-apply the small-context floor (raise-only) for the genuinely new
# window so the invalidated budgets below recompute coherently —
# percent and tokens must derive from the same window. Skipped on
# bare test instances built via object.__new__ that never ran
# __init__ (no _base_threshold_percent).
_base = getattr(self, "_base_threshold_percent", None)
if _base is not None:
self.threshold_percent = self._effective_threshold_percent(
value, _base,
)
self._threshold_tokens = None
self._tail_token_budget = None
self._max_summary_tokens = None
self._emit_init_summary_once()
@property
def threshold_tokens(self) -> int:
if self._threshold_tokens is None:
# Resolve the window FIRST (may apply the small-context floor to
# threshold_percent as a side effect) so the percent read below
# is the floored value regardless of argument evaluation order.
_ctx = self.context_length
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even
# if the percentage would suggest a lower value (#14690 handles
# the degenerate small-window case inside the helper).
self._threshold_tokens = self._compute_threshold_tokens(
_ctx, self.threshold_percent, self.max_tokens,
)
# Apply absolute token cap (compression.threshold_tokens) —
# takes the lower of the ratio-based threshold and the cap.
self._apply_threshold_tokens_cap()
return self._threshold_tokens
@threshold_tokens.setter
def threshold_tokens(self, value: int) -> None:
self._threshold_tokens = value
@property
def tail_token_budget(self) -> int:
if self._tail_token_budget is None:
self._tail_token_budget = int(self.threshold_tokens * self.summary_target_ratio)
return self._tail_token_budget
@tail_token_budget.setter
def tail_token_budget(self, value: int) -> None:
self._tail_token_budget = value
@property
def max_summary_tokens(self) -> int:
if self._max_summary_tokens is None:
self._max_summary_tokens = min(
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
)
return self._max_summary_tokens
@max_summary_tokens.setter
def max_summary_tokens(self, value: int) -> None:
self._max_summary_tokens = value
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Clear all per-session compaction state at a real session boundary.
@ -1988,56 +2107,30 @@ class ContextCompressor(ContextEngine):
# deterministic "summary unavailable" handoff and drop the middle window.
self.abort_on_summary_failure = abort_on_summary_failure
self.context_length = get_model_context_length(
model, base_url=base_url, api_key=api_key,
config_context_length=config_context_length,
provider=provider,
)
# Small-context threshold floor: models under 512K trigger at >=75%
# so compaction doesn't fire with half the window still free (the
# incompressible floor makes 50%-triggered compaction thrash on
# 128K-262K models). Raise-only; must run AFTER context_length is
# resolved and BEFORE threshold_tokens is derived. The pre-floor
# value is kept so update_model() can re-derive for a new window
# (switching small -> large must drop back to the configured value).
# Note: _base_threshold_percent already has the per-model override
# applied, so the floor stacks on top of any model-specific threshold.
# Defer context-length resolution to first access (#32221):
# get_model_context_length() can issue a synchronous /models HTTP
# probe, which must not block AIAgent construction. The small-context
# threshold floor and the absolute threshold cap both need the
# resolved window, so they are applied on first resolution (see
# _resolve_context_length / the threshold_tokens property) instead
# of here. update_model() re-derives the floor for a new window from
# _config_threshold_percent (the raw config value snapshotted above),
# so switching small -> large correctly drops back to the configured
# value.
self._config_context_length = config_context_length
self._configured_threshold_percent = self.threshold_percent
self.threshold_percent = self._effective_threshold_percent(
self.context_length, self._base_threshold_percent,
)
threshold_percent = self.threshold_percent
# Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if
# the percentage would suggest a lower value. This prevents premature
# compression on large-context models at 50% while keeping the % sane
# for models right at the minimum. _compute_threshold_tokens also
# guards the degenerate case where the floor would equal/exceed the
# window (small models), so auto-compression can still fire (#14690).
self.threshold_tokens = self._compute_threshold_tokens(
self.context_length, threshold_percent, self.max_tokens,
)
# Apply absolute token cap (compression.threshold_tokens) — takes
# the lower of the ratio-based threshold and the cap.
self._apply_threshold_tokens_cap()
self._resolved_context_length: int | None = None
self._threshold_tokens: int | None = None
self._tail_token_budget: int | None = None
self._max_summary_tokens: int | None = None
self.compression_count = 0
# Derive token budgets: ratio is relative to the threshold, not total context
target_tokens = int(self.threshold_tokens * self.summary_target_ratio)
self.tail_token_budget = target_tokens
self.max_summary_tokens = min(
int(self.context_length * 0.05), _SUMMARY_TOKENS_CEILING,
)
if not quiet_mode:
logger.info(
"Context compressor initialized: model=%s context_length=%d "
"threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d "
"provider=%s base_url=%s",
model, self.context_length, self.threshold_tokens,
threshold_percent * 100, self.summary_target_ratio * 100,
self.tail_token_budget,
provider or "none", base_url or "none",
)
# The "initialized" log reports resolved token budgets, which would
# force the deferred get_model_context_length() probe to run inside
# __init__ and re-introduce the exact synchronous blocking this change
# removes (#32221). Emit it on first context-length resolution instead
# so construction stays non-blocking on every path (not just quiet).
self._log_init_summary = not quiet_mode
self._context_probed = False # True after a step-down from context error
self.last_prompt_tokens = 0

View file

@ -2721,16 +2721,28 @@ def try_shrink_image_parts_in_messages(
media_type = "image/jpeg"
return f"data:{media_type};base64,{data}"
def _write_data_url_to_source(source: dict, data_url: str) -> None:
def _write_data_url_to_source(source: dict, data_url: str) -> dict:
"""Return a NEW source dict carrying the re-encoded payload.
Copy-on-write: content parts on the per-call ``api_messages`` list may
be shared references into the persistent conversation history (the
per-message copy is shallow, and cache decoration only deep-copies the
marked messages). Mutating the existing dict would rewrite the stored
transcript with the degraded image so the caller replaces the part,
never edits it in place.
"""
header, _, data = data_url.partition(",")
media_type = "image/jpeg"
if header.startswith("data:"):
candidate = header[len("data:"):].split(";", 1)[0].strip()
if candidate.startswith("image/"):
media_type = candidate
source["type"] = "base64"
source["media_type"] = media_type
source["data"] = data
return {
**source,
"type": "base64",
"media_type": media_type,
"data": data,
}
for msg in api_messages:
if not isinstance(msg, dict):
@ -2738,7 +2750,13 @@ def try_shrink_image_parts_in_messages(
content = msg.get("content")
if not isinstance(content, list):
continue
for part in content:
# Copy-on-write per message: never mutate part/source dicts in place —
# they can alias the stored conversation history (see
# _write_data_url_to_source). Build a replacement content list on the
# first shrunken part and reassign msg["content"] (a top-level write on
# the per-call message copy, which never reaches history).
new_content: list | None = None
for part_idx, part in enumerate(content):
if not isinstance(part, dict):
continue
ptype = part.get("type")
@ -2747,7 +2765,12 @@ def try_shrink_image_parts_in_messages(
url = _source_to_data_url(source)
resized, unshrinkable = _shrink_data_url(url or "")
if resized and isinstance(source, dict):
_write_data_url_to_source(source, resized)
if new_content is None:
new_content = list(content)
new_content[part_idx] = {
**part,
"source": _write_data_url_to_source(source, resized),
}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
@ -2761,17 +2784,26 @@ def try_shrink_image_parts_in_messages(
url = image_value.get("url", "")
resized, unshrinkable = _shrink_data_url(url)
if resized:
image_value["url"] = resized
if new_content is None:
new_content = list(content)
new_content[part_idx] = {
**part,
"image_url": {**image_value, "url": resized},
}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
elif isinstance(image_value, str):
resized, unshrinkable = _shrink_data_url(image_value)
if resized:
part["image_url"] = resized
if new_content is None:
new_content = list(content)
new_content[part_idx] = {**part, "image_url": resized}
changed_count += 1
elif unshrinkable:
unshrinkable_oversized += 1
if new_content is not None:
msg["content"] = new_content
if changed_count:
logger.info(

View file

@ -520,7 +520,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# session is created (not on continuation). Plugins can use this
# to initialise session-scoped state (e.g. warm a memory cache).
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_start",
session_id=agent.session_id,
@ -2100,7 +2100,7 @@ def run_conversation(
_llm_middleware_trace = []
try:
from hermes_cli.plugins import (
from hermes_cli.lifecycle import (
has_hook,
invoke_hook as _invoke_hook,
)
@ -2141,6 +2141,7 @@ def run_conversation(
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
retry_count=retry_count,
request_messages=list(request_messages)
if isinstance(request_messages, list)
else [],
@ -2230,7 +2231,28 @@ def run_conversation(
return agent._interruptible_streaming_api_call(
next_api_kwargs, on_first_delta=_stop_spinner
)
return agent._interruptible_api_call(next_api_kwargs)
from agent import relay_llm
return relay_llm.execute(
next_api_kwargs,
agent._interruptible_api_call,
session_id=str(agent.session_id or ""),
name=str(agent.provider or "provider"),
model_name=str(agent.model or ""),
metadata={
"api_mode": agent.api_mode,
"api_request_id": api_request_id,
"call_role": (
"delegated"
if getattr(agent, "is_subagent", False)
else "fallback"
if int(getattr(agent, "_fallback_index", 0) or 0) > 0
else "primary"
),
"retry_count": retry_count,
},
defer_logical_completion=True,
)
from hermes_cli.middleware import run_llm_execution_middleware
@ -3217,7 +3239,12 @@ def run_conversation(
_cost_delta = (_cost_delta or 0.0) + float(_moa_ref_cost)
except (TypeError, ValueError): # pragma: no cover
pass
agent._session_db.update_token_counts(
# Enqueued, not written: the background writer
# applies the delta off the turn thread (a cold
# state.db UPDATE here stalled the tool loop for
# up to hundreds of ms per API call). Drained at
# turn finalize via _persist_session.
agent._session_db.queue_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
@ -3283,6 +3310,12 @@ def run_conversation(
clear_nous_rate_limit()
except Exception:
pass
from agent import relay_llm
relay_llm.complete_logical_call(
api_request_id,
outcome="success",
)
agent._touch_activity(f"API call #{api_call_count} completed")
break # Success, exit retry loop
@ -5427,7 +5460,7 @@ def run_conversation(
assistant_message.content = str(raw)
try:
from hermes_cli.plugins import (
from hermes_cli.lifecycle import (
has_hook,
invoke_hook as _invoke_hook,
)
@ -6760,7 +6793,8 @@ def run_conversation(
_attempt = getattr(agent, "_pre_verify_nudges", 0)
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
from hermes_cli.lifecycle import has_hook
from hermes_cli.plugins import get_pre_verify_continue_message
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
# Posture is fixed for the session — resolve once + cache.

View file

@ -170,6 +170,27 @@ CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = (
)
CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice
# Minimum subscription balance that counts as "grant not yet spent" for the
# grant_spent crossing gate (see evaluate_credits_notices). 1¢: portal-seeded
# states derive micros from float dollars and can carry sub-cent residue where
# the inference headers report exactly 0 — without this floor such a seed
# opens the gate and the first header re-creates the at-open nag.
GRANT_UNSPENT_MIN_MICROS = 10_000
def new_credits_latch() -> dict:
"""Fresh notice latch in the shape :func:`evaluate_credits_notices` expects.
The policy owns this schema every producer (agent build, lazy re-init,
tests) must build the latch through here so a new gate key lands everywhere
at once instead of drifting across hand-rolled literals."""
return {
"active": set(),
"seen_below_90": False,
"usage_band": None,
"seen_grant_unspent": False,
}
# ── AgentNotice (out-of-band notice payload; driver-agnostic) ────────────────
@ -250,7 +271,8 @@ def evaluate_credits_notices(
) -> tuple[list[AgentNotice], list[str]]:
"""Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE.
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}.
latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int],
"seen_grant_unspent": bool}.
``model_is_free``: True when the session's active model is a Nous free-tier
model (see :func:`is_free_tier_model`). Suppresses the ``credits.depleted``
@ -277,6 +299,18 @@ def evaluate_credits_notices(
if uf is not None and uf < _lowest_band:
latch["seen_below_90"] = True # gate opened: usage-band notices may now fire
# Grant-spent crossing gate: grant_spent may fire only after this session
# has OBSERVED the grant meaningfully unspent (≥1¢ left — see
# GRANT_UNSPENT_MIN_MICROS). Opening at grant-spent is a steady STATE, not
# an event — /usage carries it; only a live in-session crossing announces.
# Unlike seen_below_90, seeds must NOT prime this gate.
if (
uf is not None
and uf < 1.0
and state.subscription_micros >= GRANT_UNSPENT_MIN_MICROS
):
latch["seen_grant_unspent"] = True
active = latch["active"]
# ── Conditions ───────────────────────────────────────────────────────────
@ -341,7 +375,17 @@ def evaluate_credits_notices(
latch["usage_band"] = target_band
# ── grant_spent ──────────────────────────────────────────────────────────
if grant_cond and "credits.grant_spent" not in active:
# The crossing gate guards only the SHOW and is CONSUMED by it — one
# announcement per crossing. A header flicker (uf → None → back to 1.0)
# clears the sticky line via grant_cond but cannot re-announce; only a
# renewal that re-opens the gate (a fresh ≥1¢ observation) arms the next
# announcement. .get(): default closed for any hand-built latch missing
# the key, so a first observation can never fire this notice.
if (
grant_cond
and "credits.grant_spent" not in active
and latch.get("seen_grant_unspent", False)
):
to_show.append(
AgentNotice(
text=f"• Grant spent · ${state.purchased_usd} top-up left",
@ -352,6 +396,7 @@ def evaluate_credits_notices(
)
)
active.add("credits.grant_spent")
latch["seen_grant_unspent"] = False
elif "credits.grant_spent" in active and not grant_cond:
to_clear.append("credits.grant_spent")
active.discard("credits.grant_spent")
@ -627,7 +672,8 @@ _DEV_FIXTURES: dict[str, dict] = {
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
denominator_kind="subscription_cap", paid_access=True,
),
"grant_exhausted": dict( # used_fraction == 1.0 + purchased>0 → credits.grant_spent
"grant_exhausted": dict( # uf == 1.0 + purchased>0 → SILENT at open (crossing-gated);
# flip healthy → grant_exhausted via the fixture-file path to see credits.grant_spent
remaining_micros=12_340_000, remaining_usd="12.34",
subscription_micros=0, subscription_usd="0.00",
subscription_limit_micros=20_000_000, subscription_limit_usd="20.00",
@ -741,6 +787,9 @@ def _hydrate_seed_state(agent, state) -> None:
agent._credits_session_start_micros = state.remaining_micros
_latch = getattr(agent, "_credits_latch", None)
if isinstance(_latch, dict) and state.used_fraction is not None:
# Prime ONLY seen_below_90 (open-high band warnings are wanted at open).
# Never prime seen_grant_unspent here: a seed observing grant-spent is a
# steady state, and priming it would revive the every-session nag.
_latch["seen_below_90"] = True
emit = getattr(agent, "_emit_credits_notices", None)
if callable(emit):

View file

@ -73,7 +73,7 @@ def probe_gemini_tier(
api_key: str,
base_url: str = DEFAULT_GEMINI_BASE_URL,
*,
model: str = "gemini-2.5-flash",
model: str = "gemini-3.6-flash",
timeout: float = 10.0,
) -> str:
"""Probe a Google AI Studio API key and return its tier.
@ -154,8 +154,8 @@ def is_free_tier_quota_error(error_message: str) -> bool:
_FREE_TIER_GUIDANCE = (
"\n\nYour Google API key is on the free tier (<= 250 requests/day for "
"gemini-2.5-flash). Hermes typically makes 3-10 API calls per user turn, "
"\n\nYour Google API key is on the free tier (a few hundred requests/day "
"for Gemini Flash models). Hermes typically makes 3-10 API calls per user turn, "
"so the free tier is exhausted in a handful of messages and cannot sustain "
"an agent session. Enable billing on your Google Cloud project and "
"regenerate the key in a billing-enabled project: "
@ -321,9 +321,13 @@ def _translate_tool_result_to_gemini(
) -> Dict[str, Any]:
tool_name_by_call_id = tool_name_by_call_id or {}
tool_call_id = str(message.get("tool_call_id") or "")
# A tool result can carry the unwrapped internal tool name (for example,
# an MCP tool invoked through the `tool_call` bridge). Gemini requires
# functionResponse.name to echo the matching functionCall.name, so the
# call-id mapping must take precedence over the internal result name.
name = str(
message.get("name")
or tool_name_by_call_id.get(tool_call_id)
tool_name_by_call_id.get(tool_call_id)
or message.get("name")
or tool_call_id
or "tool"
)
@ -976,7 +980,7 @@ class GeminiNativeClient:
def _create_chat_completion(
self,
*,
model: str = "gemini-2.5-flash",
model: str = "gemini-3.6-flash",
messages: Optional[List[Dict[str, Any]]] = None,
stream: bool = False,
tools: Any = None,

View file

@ -113,6 +113,13 @@ class InsightsEngine:
"""
cutoff = time.time() - (days * 86400)
# Token/cost totals may still sit on the SessionDB's async
# accounting queue; drain so the report reflects exact counters.
# (self.db may be a raw sqlite3 connection in tests — guard.)
flush = getattr(self.db, "flush_token_counts", None)
if callable(flush):
flush()
# Gather raw data
sessions = self._get_sessions(cutoff, source)
tool_usage = self._get_tool_usage(cutoff, source)

View file

@ -40,7 +40,7 @@ from __future__ import annotations
import logging
import os
import threading
from typing import Tuple
from typing import List, Tuple
# Dedicated logger name so the documented grep recipe survives a
# ``logging.getLogger(__name__)`` rename of any internal module.
@ -188,6 +188,25 @@ def log_spawn_failed(server_id: str, workspace_root: str, exc: BaseException) ->
)
def log_reaped(keys: List[Tuple[str, str]], idle_timeout: float) -> None:
"""Idle clients were shut down by the reaper. INFO — one line per
sweep so users can correlate memory drops with LSP activity.
Also clears the ``log_active`` announce cache for the reaped keys so
a later respawn re-announces at INFO instead of logging a misleading
DEBUG "reused client".
"""
with _announce_lock:
for key in keys:
_announced_active.discard(key)
summary = ", ".join(f"{sid} ({root})" for sid, root in keys)
_emit(
"reaper",
logging.INFO,
f"reaped {len(keys)} idle client(s) after {idle_timeout:.0f}s: {summary}",
)
def reset_announce_caches() -> None:
"""Test-only: clear the dedup caches. Production code never calls this."""
with _announce_lock:
@ -209,5 +228,6 @@ __all__ = [
"log_timeout",
"log_server_error",
"log_spawn_failed",
"log_reaped",
"reset_announce_caches",
]

View file

@ -59,6 +59,7 @@ from agent.lsp.workspace import (
logger = logging.getLogger("agent.lsp.manager")
DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped
MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget
class _BackgroundLoop:
@ -176,6 +177,7 @@ class LSPService:
self._spawning: Dict[Tuple[str, str], asyncio.Future] = {}
self._last_used: Dict[Tuple[str, str], float] = {}
self._state_lock = threading.Lock()
self._idle_reaper_task: Optional[asyncio.Task] = None
# Delta baseline: file path → snapshot of diagnostics taken
# immediately before a write. ``get_diagnostics_sync`` filters
@ -183,6 +185,9 @@ class LSPService:
# introduced by the current edit.
self._delta_baseline: Dict[str, List[Dict[str, Any]]] = {}
if self._enabled and self._idle_timeout > 0:
self._loop.run(self._start_idle_reaper(), timeout=2.0)
@classmethod
def create_from_config(cls) -> Optional["LSPService"]:
"""Build a service from ``hermes_cli.config`` settings.
@ -205,6 +210,16 @@ class LSPService:
wait_mode = lsp_cfg.get("wait_mode", "document")
wait_timeout = float(lsp_cfg.get("wait_timeout", DIAGNOSTICS_DOCUMENT_WAIT))
install_strategy = lsp_cfg.get("install_strategy", "auto")
try:
idle_timeout = float(lsp_cfg.get("idle_timeout", DEFAULT_IDLE_TIMEOUT))
except (TypeError, ValueError):
idle_timeout = DEFAULT_IDLE_TIMEOUT
if 0 < idle_timeout < MIN_IDLE_TIMEOUT:
# A timeout below the per-operation wait budget could reap a
# client mid-flight; the resulting outer timeout would then
# mark the (server, workspace) pair broken for the process
# lifetime. Clamp to a safe floor (0 still disables).
idle_timeout = MIN_IDLE_TIMEOUT
servers_cfg = lsp_cfg.get("servers") or {}
disabled = []
binary_overrides: Dict[str, List[str]] = {}
@ -235,6 +250,7 @@ class LSPService:
env_overrides=env_overrides,
init_overrides=init_overrides,
disabled_servers=disabled,
idle_timeout=idle_timeout,
)
# ------------------------------------------------------------------
@ -434,6 +450,7 @@ class LSPService:
# ``_clients`` with a half-initialized state.
with self._state_lock:
client = self._clients.pop(key, None)
self._last_used.pop(key, None)
if client is not None:
try:
# Fire-and-forget shutdown — give it a second to cleanup,
@ -470,7 +487,7 @@ class LSPService:
except Exception as e: # noqa: BLE001
logger.debug("snapshot open/wait failed: %s", e)
return []
self._last_used[(client.server_id, client.workspace_root)] = time.time()
self._touch(client)
if not fresh:
# No fresh data for the pre-edit content — an empty baseline
# is safe: worst case the delta filter removes less, never
@ -499,7 +516,7 @@ class LSPService:
except Exception as e: # noqa: BLE001
logger.debug("open/wait failed for %s: %s", file_path, e)
return None
self._last_used[(client.server_id, client.workspace_root)] = time.time()
self._touch(client)
if not fresh:
return None
return list(client.diagnostics_for(file_path, fresh_only=True))
@ -539,6 +556,7 @@ class LSPService:
with self._state_lock:
client = self._clients.get(key)
if client is not None and client.is_running:
self._last_used[key] = time.time()
eventlog.log_active(srv.server_id, per_server_root)
return client
spawning = self._spawning.get(key)
@ -589,7 +607,7 @@ class LSPService:
return None
with self._state_lock:
self._clients[key] = client
self._last_used[key] = time.time()
self._last_used[key] = time.time()
eventlog.log_active(srv.server_id, per_server_root)
spawn_future.set_result(client)
return client
@ -597,7 +615,63 @@ class LSPService:
with self._state_lock:
self._spawning.pop(key, None)
async def _start_idle_reaper(self) -> None:
self._idle_reaper_task = asyncio.create_task(self._idle_reaper_loop())
def _touch(self, client: LSPClient) -> None:
"""Refresh the last-used timestamp for a client we just used.
Guarded on membership so a reaped-mid-operation client can't
resurrect an orphan ``_last_used`` entry after the reaper popped
the key. All writers and the reaper run on the background loop
thread; the lock keeps this consistent with the reader anyway.
"""
key = (client.server_id, client.workspace_root)
with self._state_lock:
if key in self._clients:
self._last_used[key] = time.time()
async def _idle_reaper_loop(self) -> None:
interval = min(60.0, self._idle_timeout)
while True:
await asyncio.sleep(interval)
try:
await self._reap_idle_once()
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
# A transient sweep error must not kill the reaper —
# otherwise one bad shutdown permanently re-opens the
# unbounded-accumulation leak this loop exists to fix.
logger.debug("LSP idle reaper sweep error: %s", e)
async def _reap_idle_once(self) -> None:
cutoff = time.time() - self._idle_timeout
with self._state_lock:
idle_keys = [
key
for key in self._clients
if self._last_used.get(key, 0) < cutoff
]
clients = [self._clients.pop(key) for key in idle_keys]
for key in idle_keys:
self._last_used.pop(key, None)
if clients:
eventlog.log_reaped(
[(c.server_id, c.workspace_root) for c in clients],
self._idle_timeout,
)
await asyncio.gather(
*(client.shutdown() for client in clients),
return_exceptions=True,
)
async def _shutdown_async(self) -> None:
reaper = self._idle_reaper_task
self._idle_reaper_task = None
if reaper is not None:
reaper.cancel()
await asyncio.gather(reaper, return_exceptions=True)
with self._state_lock:
clients = list(self._clients.values())
self._clients.clear()

View file

@ -2429,21 +2429,27 @@ def get_model_context_length(
if context_length is not None:
return context_length
if not _is_known_provider_base_url(base_url):
# 2b. Ollama native /api/show — any URL might be an Ollama server
# (local, cloud, or custom hosting). Non-Ollama servers return
# 404/405 quickly. Fall through on failure.
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 3. Try querying local server directly
# For local endpoints, run the probe that respects configured
# Modelfile context values first. _query_local_context_length
# prefers num_ctx from Modelfile, while _query_ollama_api_show
# returns the GGUF training max first which can be larger and
# would create a false-safe window for compression (#63122).
# Non-local endpoints preserve the existing GGUF-first behavior.
if is_local_endpoint(base_url):
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if local_ctx and local_ctx > 0:
if not _skip_persistent_context_cache(base_url, provider):
_maybe_cache_local_context_length(model, base_url, local_ctx)
return local_ctx
# 2b. Ollama native /api/show — non-local endpoints preserve
# the existing generic /api/show GGUF-first behavior.
# Non-Ollama servers return 404/405 quickly.
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 3. Probe-down fallback after endpoint-specific detection failed
logger.info(
"Could not detect context length for model %r at %s"
"defaulting to %s tokens (probe-down). Set model.context_length "

View file

@ -8,11 +8,15 @@ of 4000+ models across 109+ providers. Provides:
(reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff,
open-weights flag, family grouping, deprecation status
Data resolution order (like TypeScript OpenCode):
1. Bundled snapshot (ships with the package offline-first)
2. Disk cache (~/.hermes/models_dev_cache.json)
3. Network fetch (https://models.dev/api.json)
4. Background refresh every 60 minutes
Data resolution order:
1. In-memory cache (fresh, or stale served immediately while a single
background daemon thread refreshes)
2. Disk cache (~/.hermes/models_dev_cache.json any age; stale data is
served rather than blocking callers on the network)
3. Network fetch (https://models.dev/api.json) only when no cache
exists at all; failed refreshes back off for 5 minutes process-wide
Latency-sensitive callers (gateway route-identity checks) pass
``allow_network=False`` and never touch the network.
Other modules should import the dataclasses and query functions from here
rather than parsing the raw JSON themselves.
@ -20,6 +24,7 @@ rather than parsing the raw JSON themselves.
import json
import logging
import threading
import time
from dataclasses import dataclass
from pathlib import Path
@ -33,10 +38,15 @@ logger = logging.getLogger(__name__)
MODELS_DEV_URL = "https://models.dev/api.json"
_MODELS_DEV_CACHE_TTL = 3600 # 1 hour in-memory
_MODELS_DEV_RETRY_DELAY = 300 # 5 minutes after a failed refresh
# In-memory cache
_models_dev_cache: Dict[str, Any] = {}
_models_dev_cache_time: float = 0
_models_dev_retry_after: float = 0
_models_dev_fetch_lock = threading.Lock()
_models_dev_refresh_lock = threading.Lock()
_models_dev_refresh_in_flight = False
# ---------------------------------------------------------------------------
@ -237,27 +247,152 @@ def _save_disk_cache(data: Dict[str, Any]) -> None:
logger.debug("Failed to save models.dev disk cache: %s", e)
def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
def _fetch_models_dev_from_network() -> Dict[str, Any]:
"""Fetch the live models.dev registry without touching local caches.
Raises on network errors and on an empty/invalid registry payload.
"""
response = requests.get(MODELS_DEV_URL, timeout=15)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict) or not data:
raise ValueError("models.dev returned an empty or invalid registry")
return data
def _mark_stale_cache_grace() -> None:
"""Give stale cache data a short in-memory grace before retrying refresh.
Only ever moves the timestamp forward: if a background refresh completed
between the caller's staleness check and this call, the fresh timestamp
is preserved instead of being rewound to a 5-minute grace.
"""
global _models_dev_cache_time
grace_time = time.time() - _MODELS_DEV_CACHE_TTL + _MODELS_DEV_RETRY_DELAY
if grace_time > _models_dev_cache_time:
_models_dev_cache_time = grace_time
def _commit_registry(data: Dict[str, Any], *, where: str) -> None:
"""Persist a freshly fetched registry: disk + in-mem + clear backoff.
Callers must hold ``_models_dev_fetch_lock`` so a failing refresh on one
path can never stomp the state a succeeding refresh on the other path
just committed (e.g. a failing background worker re-arming the backoff
immediately after a successful ``force_refresh``).
"""
global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after
_save_disk_cache(data)
_models_dev_cache = data
_models_dev_cache_time = time.time()
_models_dev_retry_after = 0
logger.debug(
"Refreshed models.dev registry (%s): %d providers, %d total models",
where,
len(data),
sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
)
def _note_refresh_failure(exc: Exception, *, where: str) -> None:
"""Record a failed refresh: arm the process-wide 5-minute backoff.
Callers must hold ``_models_dev_fetch_lock`` (see ``_commit_registry``).
"""
global _models_dev_retry_after
_models_dev_retry_after = time.time() + _MODELS_DEV_RETRY_DELAY
logger.debug(
"models.dev refresh failed (%s); retry suppressed for %ds: %s",
where,
_MODELS_DEV_RETRY_DELAY,
exc,
)
def _background_refresh_models_dev() -> None:
"""Best-effort refresh after serving stale cache data."""
global _models_dev_refresh_in_flight
try:
data = _fetch_models_dev_from_network()
with _models_dev_fetch_lock:
_commit_registry(data, where="background")
except Exception as e:
with _models_dev_fetch_lock:
_note_refresh_failure(e, where="background")
finally:
with _models_dev_refresh_lock:
_models_dev_refresh_in_flight = False
def _start_background_refresh_models_dev() -> None:
"""Start one daemon refresh worker if none is already running.
Honors the process-wide failure backoff: after a failed refresh,
no new background worker is spawned until ``_models_dev_retry_after``.
"""
global _models_dev_refresh_in_flight
if time.time() < _models_dev_retry_after:
return
with _models_dev_refresh_lock:
if _models_dev_refresh_in_flight:
return
_models_dev_refresh_in_flight = True
thread = threading.Thread(
target=_background_refresh_models_dev,
name="models-dev-refresh",
daemon=True,
)
try:
thread.start()
except Exception as e:
# Thread/fd exhaustion: clear the flag so refresh isn't disabled
# for the rest of the process lifetime. Callers still get stale data.
with _models_dev_refresh_lock:
_models_dev_refresh_in_flight = False
logger.debug("Failed to start models.dev refresh thread: %s", e)
def fetch_models_dev(
force_refresh: bool = False, *, allow_network: bool = True
) -> Dict[str, Any]:
"""Fetch models.dev registry. Cache hierarchy: in-mem → disk → network.
Returns the full registry dict keyed by provider ID, or empty dict on failure.
Cache hierarchy (when ``force_refresh=False``):
1. In-memory cache, populated and < TTL old return immediately.
2. **Disk cache file < TTL old by mtime load, populate in-mem, return.**
No network call. Saves ~500 ms per cold-start agent construction;
``models.dev`` only changes when providers add new models, so a
1 hour staleness window is acceptable (same TTL as in-mem cache).
3. Network fetch on success, save to disk + in-mem and return.
4. Network fails fall back to ANY available disk cache (even stale)
with a short 5 min in-mem grace period before retrying network.
1. Fresh in-memory cache return immediately.
2. Stale in-memory cache return immediately and refresh in a single
background daemon thread. Callers never block on the network while
any cache exists; ``models.dev`` only changes when providers add
new models, so stale data is preferable to a foreground timeout.
3. Disk cache file (any age) load, populate in-mem, return
immediately. Stale disk caches trigger the same background refresh.
4. No cache at all singleflight foreground network fetch. On
success, save to disk + in-mem and return.
5. Any failed refresh (foreground or background) suppresses further
automatic refreshes for 5 minutes process-wide.
When ``force_refresh=True`` (used by ``hermes config refresh``, the
\"refresh model catalog\" code path), stages 1 and 2 are skipped. The
function always hits the network and only falls back to disk if the
network call fails.
\"refresh model catalog\" code path), cache fast paths and the failure
backoff are bypassed; the function hits the network and only falls back
to cached data if the call fails. When ``allow_network=False``, any
memory or disk cache is returned regardless of age and no request is
made used by latency-sensitive paths (gateway route-identity checks)
that must never wait on the network.
"""
global _models_dev_cache, _models_dev_cache_time
global _models_dev_cache, _models_dev_cache_time, _models_dev_retry_after
if not allow_network:
if _models_dev_cache:
return _models_dev_cache
disk_data = _load_disk_cache()
if disk_data:
_models_dev_cache = disk_data
disk_age = _disk_cache_age_seconds()
_models_dev_cache_time = (
time.time() - disk_age if disk_age is not None else 0
)
return _models_dev_cache
# Stage 1: fresh in-memory cache wins. This is the hot path on
# long-lived processes — no I/O, no system calls.
@ -268,54 +403,82 @@ def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
):
return _models_dev_cache
# Stage 2: fresh-by-mtime disk cache short-circuits the network call.
# Only kicks in on cold-start processes (in-mem cache is empty or
# expired) and only when the user hasn't asked for a forced refresh.
# Skipped if the disk cache file is missing, unreadable, or older
# than _MODELS_DEV_CACHE_TTL.
# Stage 2: stale in-memory cache is still better than blocking provider
# resolution on a foreground network timeout. Refresh it in the background.
if not force_refresh and _models_dev_cache:
_mark_stale_cache_grace()
_start_background_refresh_models_dev()
logger.debug(
"Using stale in-memory models.dev cache; refreshing in background"
)
return _models_dev_cache
# Stage 3: disk cache short-circuits the network call.
# Only kicks in on cold-start processes (in-mem cache is empty) and only
# when the user hasn't asked for a forced refresh. A stale disk cache is
# deliberately usable: provider/model resolution should not hang just
# because models.dev is unreachable.
if not force_refresh:
disk_age = _disk_cache_age_seconds()
if disk_age is not None and disk_age < _MODELS_DEV_CACHE_TTL:
if disk_age is not None:
disk_data = _load_disk_cache()
if disk_data:
_models_dev_cache = disk_data
# Anchor in-mem TTL to the disk file's age so we don't
# extend an already-aging cache by another full hour.
_models_dev_cache_time = time.time() - disk_age
logger.debug(
"Loaded models.dev from fresh disk cache "
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
)
if disk_age < _MODELS_DEV_CACHE_TTL:
# Anchor in-mem TTL to the disk file's age so we don't
# extend an already-aging cache by another full hour.
_models_dev_cache_time = time.time() - disk_age
logger.debug(
"Loaded models.dev from fresh disk cache "
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
)
else:
_mark_stale_cache_grace()
_start_background_refresh_models_dev()
logger.debug(
"Using stale models.dev disk cache (age=%.0fs); "
"refreshing in background",
disk_age,
)
return _models_dev_cache
# Stage 3: network fetch.
try:
response = requests.get(MODELS_DEV_URL, timeout=15)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data:
_models_dev_cache = data
_models_dev_cache_time = time.time()
_save_disk_cache(data)
logger.debug(
"Fetched models.dev registry: %d providers, %d total models",
len(data),
sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
)
# Failed automatic refreshes are process-wide. Avoid making every caller
# retry the same unreachable endpoint while no usable cache exists.
if not force_refresh and time.time() < _models_dev_retry_after:
return _models_dev_cache
# Stage 4: singleflight foreground network fetch — only reached when no
# memory or disk cache exists (or on force_refresh). Recheck state after
# acquiring the lock because another caller may have refreshed or
# established backoff while we waited.
with _models_dev_fetch_lock:
now = time.time()
if not force_refresh:
if _models_dev_cache:
return _models_dev_cache
if now < _models_dev_retry_after:
return _models_dev_cache
try:
data = _fetch_models_dev_from_network()
_commit_registry(data, where="foreground")
return data
except Exception as e:
logger.debug("Failed to fetch models.dev: %s", e)
except Exception as e:
_note_refresh_failure(e, where="foreground")
# Stage 4: network failed — fall back to whatever disk cache exists,
# even if it's stale. Give it a short 5 min in-mem TTL so we retry
# the network soon instead of serving stale data for a full hour.
if not _models_dev_cache:
_models_dev_cache = _load_disk_cache()
if _models_dev_cache:
_models_dev_cache_time = time.time() - _MODELS_DEV_CACHE_TTL + 300
logger.debug("Loaded models.dev from disk cache (%d providers)", len(_models_dev_cache))
# Stage 5: network failed — return any stale memory/disk cache. Cache
# freshness remains expired; the retry-after timestamp controls when
# the next automatic request is allowed.
if not _models_dev_cache:
_models_dev_cache = _load_disk_cache()
_models_dev_cache_time = 0
if _models_dev_cache:
logger.debug(
"Loaded stale models.dev disk cache (%d providers)",
len(_models_dev_cache),
)
return _models_dev_cache
return _models_dev_cache
def lookup_models_dev_context(provider: str, model: str) -> Optional[int]:
@ -671,7 +834,9 @@ def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
# Provider-level queries
# ---------------------------------------------------------------------------
def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
def get_provider_info(
provider_id: str, *, allow_network: bool = True
) -> Optional[ProviderInfo]:
"""Get full provider metadata from models.dev.
Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev
@ -680,7 +845,14 @@ def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
# Resolve Hermes ID → models.dev ID
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
data = fetch_models_dev()
# NOTE: keep the zero-argument call on the default path. Dozens of test
# sites monkeypatch fetch_models_dev with zero-arg lambdas; passing the
# kwarg unconditionally would break them all (they raise TypeError).
data = (
fetch_models_dev()
if allow_network
else fetch_models_dev(allow_network=False)
)
raw = data.get(mdev_id)
if not isinstance(raw, dict):
return None

View file

@ -187,17 +187,18 @@ def apply_anthropic_cache_control(
is retained.
Returns:
Deep copy of messages with cache_control breakpoints injected.
Shallow copy of message list with selective deep copies of modified messages.
"""
messages = copy.deepcopy(api_messages)
if not messages:
return messages
if not api_messages:
return api_messages
messages = list(api_messages)
marker = _build_marker(cache_ttl)
breakpoints_used = 0
if messages[0].get("role") == "system":
messages[0] = copy.deepcopy(messages[0])
breakpoints_used = _apply_system_cache_markers(
messages[0],
marker,
@ -213,6 +214,7 @@ def apply_anthropic_cache_control(
and _can_carry_marker(messages[i], native_anthropic=native_anthropic)
]
for idx in non_sys[-remaining:]:
messages[idx] = copy.deepcopy(messages[idx])
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
return messages

View file

@ -146,19 +146,18 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
# so we accept that community forks inheriting the same prefix are
# treated as reasoning models (a reasonable default — the upstream
# gateway timing is the same).
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
def _get_pattern(slug: str) -> re.Pattern[str]:
compiled = _PATTERN_CACHE.get(slug)
if compiled is None:
compiled = re.compile(
r"^"
+ re.escape(slug)
+ r"(?:$|[\-._])"
)
_PATTERN_CACHE[slug] = compiled
return compiled
# Pre-compile all patterns at module load time to avoid per-call regex
# compilation and thread-safety issues with the mutable _PATTERN_CACHE.
# The list is built once at import and never mutated afterwards, so it is
# safe for free-threaded Python 3.13+ without any locking. The slug is kept
# in each entry for debuggability (log/inspection), even though _match_any
# only consumes floor + pattern.
_SORTED_REASONING_FLOORS: list[tuple[str, float, re.Pattern[str]]] = [
(slug, floor, re.compile(r"^" + re.escape(slug) + r"(?:$|[\-._])"))
for slug, floor in sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
]
def _match_any(model_lower: str) -> Optional[float]:
@ -169,13 +168,8 @@ def _match_any(model_lower: str) -> Optional[float]:
order is irrelevant: longest slug wins (so ``o3-mini`` beats
``o3`` on a model like ``openai/o3-mini``).
"""
# Sort by slug length descending so longer / more-specific slugs
# win on shared prefixes (o3-mini beats o3).
sorted_floors = sorted(
_REASONING_STALE_TIMEOUT_FLOORS, key=lambda kv: -len(kv[0])
)
for slug, floor in sorted_floors:
if _get_pattern(slug).search(model_lower):
for _slug, floor, pattern in _SORTED_REASONING_FLOORS:
if pattern.search(model_lower):
return float(floor)
return None

1130
agent/relay_llm.py Normal file

File diff suppressed because it is too large Load diff

1002
agent/relay_runtime.py Normal file

File diff suppressed because it is too large Load diff

123
agent/relay_tools.py Normal file
View file

@ -0,0 +1,123 @@
"""Core NeMo Relay adapter for Hermes tool execution."""
from __future__ import annotations
import asyncio
import contextvars
import inspect
import json
import logging
from collections.abc import Callable
from typing import Any
from agent import relay_runtime
logger = logging.getLogger(__name__)
def execute(
tool_name: str,
args: dict[str, Any],
callback: Callable[[dict[str, Any]], Any],
*,
session_id: str,
metadata: dict[str, Any] | None = None,
) -> tuple[Any, dict[str, Any]]:
"""Run one tool call through Relay and return its final arguments."""
runtime, session, parent = relay_runtime.resolve_execution_context(session_id)
if runtime is None or session is None or not runtime.managed_execution_enabled():
return callback(args), args
observed_args = args
raw_result: dict[str, Any] = {}
callback_error: BaseException | None = None
callback_context = contextvars.copy_context()
def invoke(next_args: Any) -> Any:
nonlocal callback_error, observed_args
observed_args = next_args if isinstance(next_args, dict) else args
try:
result = callback_context.copy().run(callback, observed_args)
except BaseException as exc:
callback_error = exc
raise
raw_result["value"] = result
raw_result["json"] = _jsonable(result)
return raw_result["json"]
try:
managed = _run_awaitable(
runtime.run_in_session_async(
session,
runtime.relay.tools.execute,
tool_name,
_jsonable(args),
invoke,
handle=parent,
metadata=_jsonable(metadata or {}),
)
)
except BaseException as exc:
if (
callback_error is not None
and relay_runtime._is_relay_wrapped_callback_error(exc, callback_error)
):
raise callback_error
if (
isinstance(exc, Exception)
and callback_error is None
and "value" in raw_result
):
logger.warning(
"NeMo Relay tool post-processing failed after dispatch success; "
"returning the Hermes tool result",
exc_info=True,
)
return raw_result["value"], observed_args
raise
if "value" in raw_result and _json_equal(managed, raw_result["json"]):
return raw_result["value"], observed_args
if isinstance(managed, str):
return managed, observed_args
return json.dumps(_jsonable(managed), ensure_ascii=False), observed_args
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_jsonable(item) for item in value]
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
return _jsonable(model_dump(mode="json"))
except Exception:
pass
try:
return _jsonable(vars(value))
except (TypeError, AttributeError):
return str(value)
def _json_equal(left: Any, right: Any) -> bool:
try:
return json.dumps(
_jsonable(left), sort_keys=True, separators=(",", ":")
) == json.dumps(_jsonable(right), sort_keys=True, separators=(",", ":"))
except (TypeError, ValueError):
return left == right
def _run_awaitable(value: Any) -> Any:
if not inspect.isawaitable(value):
return value
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(value)
raise RuntimeError(
"Synchronous Hermes Relay tool execution cannot run on an active event-loop thread"
)

File diff suppressed because it is too large Load diff

View file

@ -438,7 +438,12 @@ def build_turn_context(
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
turn_id = str(getattr(agent, "_relay_pending_turn_id", "") or "")
if not turn_id:
turn_id = (
f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
)
agent._relay_pending_turn_id = None
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Tripwire: warn (with both turn ids) when this turn starts before the
@ -1045,7 +1050,7 @@ def build_turn_context(
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
@ -1056,6 +1061,7 @@ def build_turn_context(
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
parent_session_id=getattr(agent, "_parent_session_id", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []

View file

@ -488,7 +488,7 @@ def finalize_turn(
# 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
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
@ -510,7 +510,7 @@ def finalize_turn(
# to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
from hermes_cli.lifecycle import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
@ -669,14 +669,16 @@ def finalize_turn(
# 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
from hermes_cli.lifecycle 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,
failed=failed,
interrupted=interrupted,
turn_exit_reason=_turn_exit_reason,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)

View file

@ -511,15 +511,93 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
pricing_version="deepseek-pricing-2026-07",
),
# Google Gemini
(
"google",
"gemini-3.6-flash",
): PricingEntry(
input_cost_per_million=Decimal("1.50"),
output_cost_per_million=Decimal("7.50"),
cache_read_cost_per_million=Decimal("0.15"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/gemini-api/docs/pricing",
pricing_version="google-pricing-2026-07-28",
),
(
"google",
"gemini-3.5-flash",
): PricingEntry(
input_cost_per_million=Decimal("1.50"),
output_cost_per_million=Decimal("9.00"),
cache_read_cost_per_million=Decimal("0.15"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3.5-flash-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.30"),
output_cost_per_million=Decimal("2.50"),
cache_read_cost_per_million=Decimal("0.03"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/gemini-api/docs/pricing",
pricing_version="google-pricing-2026-07-28",
),
(
"google",
"gemini-3.1-pro",
): PricingEntry(
input_cost_per_million=Decimal("2.00"),
output_cost_per_million=Decimal("12.00"),
cache_read_cost_per_million=Decimal("0.20"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3.1-flash-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.50"),
cache_read_cost_per_million=Decimal("0.025"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3-pro-preview",
): PricingEntry(
input_cost_per_million=Decimal("2.00"),
output_cost_per_million=Decimal("12.00"),
cache_read_cost_per_million=Decimal("0.20"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-3-flash-preview",
): PricingEntry(
input_cost_per_million=Decimal("0.50"),
output_cost_per_million=Decimal("3.00"),
cache_read_cost_per_million=Decimal("0.05"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
"gemini-2.5-pro",
): PricingEntry(
input_cost_per_million=Decimal("1.25"),
output_cost_per_million=Decimal("10.00"),
cache_read_cost_per_million=Decimal("0.125"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
@ -527,9 +605,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
): PricingEntry(
input_cost_per_million=Decimal("0.15"),
output_cost_per_million=Decimal("0.60"),
cache_read_cost_per_million=Decimal("0.015"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
(
"google",
@ -537,9 +616,10 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
): PricingEntry(
input_cost_per_million=Decimal("0.10"),
output_cost_per_million=Decimal("0.40"),
cache_read_cost_per_million=Decimal("0.01"),
source="official_docs_snapshot",
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
pricing_version="google-pricing-2026-07-07",
),
# AWS Bedrock — pricing per the Bedrock pricing page.
# Bedrock charges the same per-token rates as the model provider but
@ -878,6 +958,18 @@ for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"):
]
del _base_56
# The direct Gemini provider currently exposes preview IDs for these two
# models. Keep the official snapshot keyed by both their documented stable
# names and the provider's emitted IDs so a catalog selection is billable.
for _alias, _canonical in {
"gemini-3.1-pro-preview": "gemini-3.1-pro",
"gemini-3.1-flash-lite-preview": "gemini-3.1-flash-lite",
}.items():
_OFFICIAL_DOCS_PRICING[("google", _alias)] = _OFFICIAL_DOCS_PRICING[
("google", _canonical)
]
del _alias, _canonical
def _to_decimal(value: Any) -> Optional[Decimal]:
if value is None:
@ -925,11 +1017,17 @@ def resolve_billing_route(
return BillingRoute(provider="openai", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name in {"minimax", "minimax-cn"}:
return BillingRoute(provider=provider_name, model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# Vertex AI hosts the same Gemini models as Google AI Studio; price them
# off the gemini official-docs snapshot. Strip the "google/" vendor prefix
# the OpenAI-compat endpoint requires so the pricing key matches.
if provider_name == "vertex" or base_url_host_matches(base_url or "", "aiplatform.googleapis.com"):
return BillingRoute(provider="gemini", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
# Google AI Studio (Gemini) and Vertex AI host the same Gemini models.
# Price them off the official docs snapshot — the pricing keys are
# keyed on provider='google', so normalize every Google-flavored
# provider name/host onto it. Strip the "google/" vendor prefix the
# Vertex OpenAI-compat endpoint requires so the pricing key matches.
if (
provider_name in {"google", "gemini", "vertex", "google-gemini", "google-ai-studio", "google-vertex", "vertex-ai"}
or base_url_host_matches(base_url or "", "aiplatform.googleapis.com")
or base_url_host_matches(base_url or "", "generativelanguage.googleapis.com")
):
return BillingRoute(provider="google", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "fireworks" or base_url_host_matches(base_url or "", "api.fireworks.ai"):
# Fireworks model ids look like accounts/fireworks/models/<name>;
# rsplit("/", 1)[-1] yields just <name> which is what the dict keys on.

View file

@ -14,7 +14,10 @@ import { test } from 'vitest'
import {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
hermesRuntimeImportProbe,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,
verifyHermesCli
} from './backend-probes'
@ -100,9 +103,25 @@ test('verifyHermesCli returns true when --version exits 0', () => {
})
test('verifyHermesCli swallows timeouts (does not throw)', () => {
// We can't easily provoke a real 5s hang in CI without slowing the
// We can't easily provoke a real hang in CI without slowing the
// suite, but we CAN confirm that an invocation that DOES throw
// (because the binary is missing) returns false rather than
// propagating. Same code path the timeout case takes.
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
})
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
assert.equal(DEFAULT_PROBE_TIMEOUT_MS, 15_000)
// Module constant uses process.env at load time; with no override it
// matches the default (tests run without HERMES_PROBE_TIMEOUT_MS).
assert.equal(PROBE_TIMEOUT_MS, DEFAULT_PROBE_TIMEOUT_MS)
})
test('resolveProbeTimeoutMs honours HERMES_PROBE_TIMEOUT_MS', () => {
assert.equal(resolveProbeTimeoutMs({}), DEFAULT_PROBE_TIMEOUT_MS)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '30000' }), 30_000)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '0' }), DEFAULT_PROBE_TIMEOUT_MS)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: 'nope' }), DEFAULT_PROBE_TIMEOUT_MS)
// Cap runaway values
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '999999' }), 120_000)
})

View file

@ -20,8 +20,9 @@
* actually works.
*
* Both probes are deliberately fast and forgiving:
* - 5s timeout (a hung interpreter beats forever, but we still give
* slow disks / cold caches room to breathe)
* - default 15s timeout (5s was too short on cold Windows disks / AV;
* issue #61764 death-loop) with HERMES_PROBE_TIMEOUT_MS override
* - one automatic retry after a timeout before declaring the runtime dead
* - stdio ignored (we only care about exit code; stdout/stderr are
* not surfaced to the user, just to recentHermesLog for forensics
* via the caller's catch block if it chooses)
@ -34,7 +35,82 @@
import { execFileSync } from 'node:child_process'
const PROBE_TIMEOUT_MS = 5000
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
/**
* Resolve the backend probe timeout (ms).
* Honours HERMES_PROBE_TIMEOUT_MS when it parses as a positive integer.
*/
function resolveProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.HERMES_PROBE_TIMEOUT_MS
if (raw == null || raw === '') {
return DEFAULT_PROBE_TIMEOUT_MS
}
const n = Number.parseInt(String(raw), 10)
if (!Number.isFinite(n) || n <= 0) {
return DEFAULT_PROBE_TIMEOUT_MS
}
// Clamp absurd values (ms) so a typo can't hang startup forever.
return Math.min(n, 120_000)
}
const PROBE_TIMEOUT_MS = resolveProbeTimeoutMs()
function isTimeoutError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const e = err as { code?: string; killed?: boolean; signal?: string }
if (e.killed === true) {
return true
}
if (e.code === 'ETIMEDOUT') {
return true
}
// Node marks timed-out execFileSync with SIGTERM on some platforms.
if (e.signal === 'SIGTERM') {
return true
}
return false
}
/**
* Run execFileSync; on timeout only, retry once before failing.
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
*/
function execProbeSync(
command: string,
args: string[],
options: {
cwd?: string
env?: NodeJS.ProcessEnv
stdio: 'ignore'
timeout: number
shell?: boolean
windowsHide?: boolean
}
): void {
try {
execFileSync(command, args, options)
} catch (err) {
if (!isTimeoutError(err)) {
throw err
}
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
execFileSync(command, args, options)
}
}
/**
* Return the Python snippet used to verify Hermes can import far enough to
@ -71,7 +147,7 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
}
try {
execFileSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
@ -120,7 +196,7 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
}
try {
execFileSync(hermesCommand, ['--version'], {
execProbeSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts?.shell),
@ -133,4 +209,13 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
}
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }
export {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
execProbeSync,
hermesRuntimeImportProbe,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,
verifyHermesCli
}

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
</dict>
</plist>

View file

@ -7,8 +7,13 @@ import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
readFileDataUrlForIpc,
resolveDirectoryForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
@ -24,6 +29,49 @@ async function rejectsWithCode(promise, code: string) {
})
}
test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => {
assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB)
assert.equal(clampDataUrlReadMaxMb(0), 1)
assert.equal(clampDataUrlReadMaxMb(256), 256)
assert.equal(clampDataUrlReadMaxMb(99999), 4096)
assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024)
})
test('attachment upload cap is bounded above the preview default', () => {
assert.equal(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, 256 * 1024 * 1024)
assert.ok(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES > dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB))
})
test('attachment data URL helper reads bytes above the preview default without changing that limit', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-large-attachment-'))
const source = path.join(tempDir, 'large.bin')
const previewLimit = dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB)
const content = Buffer.alloc(previewLimit + 1024, 0x5a)
try {
fs.writeFileSync(source, content)
await assert.rejects(
resolveReadableFileForIpc(source, {
maxBytes: previewLimit,
purpose: 'File preview'
}),
/file is too large/
)
const dataUrl = await readFileDataUrlForIpc(source, {
maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
mimeType: 'application/octet-stream',
purpose: 'Attachment upload'
})
assert.match(dataUrl, /^data:application\/octet-stream;base64,/)
assert.deepEqual(Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'), content)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)

View file

@ -4,9 +4,34 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
// Default / floor / ceiling for Desktop's data-URL file load (composer attach,
// image preview, etc.). The whole file is base64-buffered in main, so this is
// a memory guard — not a model limit. Settings → Chat takes a free-form MB
// value; 16 MB ships as default. The ceiling is only a typo guard (very large
// values can OOM / crash the app).
const DATA_URL_READ_DEFAULT_MAX_MB = 16
const DATA_URL_READ_MIN_MAX_MB = 1
const DATA_URL_READ_MAX_MAX_MB = 4096
// Remote file.attach sends one base64 JSON-RPC frame. Cap the dedicated attach
// reader so the payload still fits uvicorn's raised ws_max_size (384 MiB)
// after base64 + framing. Preview stays on the Settings-configurable path.
const ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
function clampDataUrlReadMaxMb(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return DATA_URL_READ_DEFAULT_MAX_MB
}
return Math.min(DATA_URL_READ_MAX_MAX_MB, Math.max(DATA_URL_READ_MIN_MAX_MB, Math.round(parsed)))
}
function dataUrlReadMaxBytesFromMb(maxMb) {
return clampDataUrlReadMaxMb(maxMb) * 1024 * 1024
}
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
@ -303,10 +328,34 @@ async function resolveReadableFileForIpc(
return { realPath, resolvedPath, stat }
}
async function readFileDataUrlForIpc(
filePath,
options: {
purpose?: string
baseDir?: fs.PathOrFileDescriptor
fs?: typeof fs
blockSensitive?: boolean
maxBytes?: number
mimeType: string
}
): Promise<string> {
const fsImpl = options.fs || fs
const { resolvedPath } = await resolveReadableFileForIpc(filePath, options)
const data = await fsImpl.promises.readFile(resolvedPath)
return `data:${options.mimeType};base64,${data.toString('base64')}`
}
export {
DATA_URL_READ_MAX_BYTES,
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret,
readFileDataUrlForIpc,
rejectUnsafePathSyntax,
resolveDirectoryForIpc,
resolveReadableFileForIpc,

View file

@ -37,7 +37,13 @@ import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { isReauthRequiredError, waitForHermesReady } from './backend-health'
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import {
canImportHermesCli,
execProbeSync,
PROBE_TIMEOUT_MS,
shouldTrustHermesOverride,
verifyHermesCli
} from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure'
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
@ -115,9 +121,13 @@ import {
switchBranch
} from './git-worktree-ops'
import {
DATA_URL_READ_MAX_BYTES,
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
dataUrlReadMaxBytesFromMb,
DEFAULT_FETCH_TIMEOUT_MS,
encryptDesktopSecret as encryptDesktopSecretStrict,
readFileDataUrlForIpc,
resolveReadableFileForIpc,
resolveRequestedPathForIpc,
resolveTimeoutMs,
@ -175,6 +185,7 @@ import {
} from './ssh-connection'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import { resolveBehindCount, shouldCountCommits } from './update-count'
import { waitForUpdateClearance } from './update-gate'
import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'
import { runRebuildWithRetry } from './update-rebuild'
import {
@ -188,6 +199,7 @@ import {
} from './update-relaunch'
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
import { spawnUpdaterProcess } from './updater-process'
import { formatBlockerMessage, formatProbeFailedMessage, scanVenvBlockers } from './venv-blocker-scan'
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
import {
computeWindowOptions,
@ -960,7 +972,7 @@ app.setAboutPanelOptions({
// Custom scheme for streaming local media (video/audio) into the renderer.
// Reading large media through `readFileDataUrl` failed: it base64-loads the
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
// whole file into memory and is hard-capped (default 16 MB, Settings → Chat),
// so any non-trivial video silently refused to load. Streaming via a protocol
// handler removes the size cap and gives the <video> element seekable,
// range-aware playback. Must be registered before the app is ready.
@ -1725,30 +1737,48 @@ const UPDATE_WAIT_POLL_MS = 1000
// updater's own progress window appears. (#50419)
const UPDATE_HANDOFF_DWELL_MS = 2500
// Gate deps shared by the primary-window boot path and the pool-backend
// spawn path. Consulting BOTH the on-disk marker and the in-process
// updateInFlight flag is load-bearing (#73822): applyUpdates kills its own
// backend BEFORE the Windows venv-blocker scan but only writes the marker
// AFTER it, so a marker-only gate lets the renderer's ~1s reconnect respawn
// a backend inside the update's own critical section — which the scan then
// reports as a blocker, aborting every update attempt.
function updateGateDeps() {
return {
hasLiveMarker: () => Boolean(readLiveUpdateMarker(HERMES_HOME)),
isUpdateInFlight: () => updateInFlight
}
}
// Block until no live update is in progress (or we hit the wait timeout).
// Emits a boot-progress phase so the renderer shows "Update in progress…"
// rather than a frozen splash. Returns true if it parked at all.
async function waitForUpdateToFinish() {
let marker = readLiveUpdateMarker(HERMES_HOME)
let announced = false
if (!marker) {
const outcome = await waitForUpdateClearance(updateGateDeps(), {
onWaitTick: async reason => {
if (!announced) {
announced = true
rememberLog(`[updates] update in progress (${reason}); deferring backend start until it finishes`)
}
await advanceBootProgress(
'backend.update-wait',
'An update is finishing — Hermes will start automatically when it completes…',
12
)
},
pollMs: UPDATE_WAIT_POLL_MS,
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
})
if (outcome === 'clear') {
return false
}
rememberLog(`[updates] update in progress (pid=${marker.pid}); deferring backend start until it finishes`)
const deadline = Date.now() + UPDATE_WAIT_TIMEOUT_MS
while (marker && Date.now() < deadline) {
await advanceBootProgress(
'backend.update-wait',
'An update is finishing — Hermes will start automatically when it completes…',
12
)
await new Promise(r => setTimeout(r, UPDATE_WAIT_POLL_MS))
marker = readLiveUpdateMarker(HERMES_HOME)
}
if (marker) {
if (outcome === 'timeout') {
rememberLog('[updates] update still in progress after wait timeout; starting backend anyway')
} else {
rememberLog('[updates] update finished; proceeding with backend start')
@ -1862,11 +1892,22 @@ function backendSupportsServe(backend) {
if (supported === null) {
try {
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
execFileSync(backend.command, [...prefix, 'serve', '--help'], {
// Same cold-Windows Python-startup class as the runtime probes
// (#61764/#72632/#72707): `serve --help` imports at least as much as
// `hermes --version` (~10.5s measured cold), and a false negative here
// is cached for the process lifetime, silently routing a modern
// runtime through the legacy `dashboard` form. Share the probe budget
// and its timeout-only retry instead of a thinner local bound.
execProbeSync(backend.command, [...prefix, 'serve', '--help'], {
cwd: backend.root || undefined,
env: { ...process.env, HERMES_HOME, ...(backend.env || {}) },
timeout: 15000,
timeout: PROBE_TIMEOUT_MS,
stdio: 'ignore',
// `.cmd`/`.bat` shim backends carry shell: true in their descriptor
// (see resolveHermesBackend step 4); execFileSync of a .cmd without
// shell throws EINVAL on modern Node, which the catch below would
// mis-cache as "serve unsupported" for the process lifetime.
shell: Boolean(backend.shell),
windowsHide: true
})
supported = true
@ -2023,7 +2064,10 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
// Registry reads are near-instant; the bound only exists so a
// pathologically wedged reg.exe can't hang the synchronous boot
// resolver forever (this ran unbounded before).
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5_000 })
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
@ -2078,7 +2122,12 @@ function findSystemPython() {
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
hiddenWindowsChildOptions({
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
stdio: ['ignore', 'pipe', 'ignore'],
// Bare interpreter startup — much lighter than the hermes-import
// probes, but still python.exe under cold cache / AV scan, so
// share the probe budget rather than running unbounded (this
// synchronous exec previously had no timeout at all).
timeout: PROBE_TIMEOUT_MS
})
)
@ -2874,6 +2923,39 @@ async function applyUpdates(opts = {}) {
return { ok: false, error: message }
}
// Preflight: after releasing our own backends, check for remaining
// Hermes processes running from this venv. The updater normally refuses
// when it detects a holder, but because the updater is spawned detached
// with stdio:ignore, the user never sees that refusal and the update
// silently fails. This preflight detects holders early and gives the
// user an actionable error. Windows-only; the .pyd lock hazard is a
// Windows phenomenon. ALL failures (blocked, missing python, timeout,
// malformed output, missing psutil) abort the handoff — never proceed
// to the detached updater when the venv state is unknown.
if (IS_WINDOWS) {
const scanOutcome = await scanVenvBlockers(updateRoot)
if (scanOutcome.kind === 'blocked') {
const message = formatBlockerMessage(scanOutcome.result)
rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`)
emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})
return { ok: false, error: 'venv-blocked', message }
}
if (scanOutcome.kind === 'probe-failure') {
const message = formatProbeFailedMessage()
rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`)
emitUpdateProgress({ stage: 'error', message, percent: null })
startHermes().catch(() => {})
return { ok: false, error: 'venv-probe-failed', message }
}
}
// Detached so the updater outlives this process — it needs us GONE before
// `hermes update` will run (the venv shim is locked while we live).
const child = spawnUpdaterProcess(updater, updaterArgs, {
@ -3822,17 +3904,20 @@ function resolveHermesBackend(backendArgs) {
// through to the install-script bootstrap if the optional probe times
// out under load; the pinned backend is the only valid runtime there.
if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return (
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: backendArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
)
// `unwrapped` above already answered "is this a Windows venv shim?" —
// it was null (not a shim, or its import probe failed). Do NOT re-run
// unwrapWindowsVenvHermesCommand here: the second call repeats the
// same un-memoized import probe, costing up to another full probe
// timeout on the boot path for an answer we already have.
return {
label: `existing Hermes CLI at ${hermesCommand}`,
command: hermesCommand,
args: backendArgs,
bootstrap: false,
env: {},
kind: 'command',
shell: shellForProbe
}
}
rememberLog(
@ -4891,6 +4976,45 @@ function closePreviewWatchers() {
}
}
/** Watch a DIRECTORY for entry churn (folders appearing/vanishing) the
* disk-plugin door's "new plugin folder" signal, replacing the renderer's 5s
* readdir poll. Same registry + change channel as the preview file watchers
* (the renderer reconciles on any tick; per-file edits stay on their own
* watches), so stopPreviewFileWatch/closePreviewWatchers manage these too. */
function watchDirectory(rawDir) {
const watchDir = path.resolve(String(rawDir || ''))
if (!fs.existsSync(watchDir) || !fs.statSync(watchDir).isDirectory()) {
throw new Error(`Not a directory: ${watchDir}`)
}
const id = crypto.randomBytes(12).toString('base64url')
let timer = null
const watcher = fs.watch(watchDir, () => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
timer = null
sendPreviewFileChanged({ id, path: watchDir, url: pathToFileURL(watchDir).toString() })
}, PREVIEW_WATCH_DEBOUNCE_MS)
})
previewWatchers.set(id, {
close: () => {
if (timer) {
clearTimeout(timer)
}
watcher.close()
}
})
return { id, path: watchDir }
}
// Best-effort read of a gateway's advertised auth providers, cached per base
// URL for the life of the process. Used by the oauth pre-flight guard to tell
// a password-provider gateway (which cannot satisfy the bearer/cookie checks
@ -5508,18 +5632,22 @@ function installContextMenu(window) {
})
}
// Microphone capture for the voice composer. The renderer drives mic access
// Microphone and camera capture. The voice composer drives mic access and
// renderer features (e.g. desktop plugins) can drive camera access, both
// through getUserMedia, which Chromium gates behind these two session hooks.
//
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
// breaks on Windows: Chromium frequently fires the mic permission request with
// an empty/undefined `mediaTypes`, so the strict check denies it and
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
// We therefore treat an audio-capture request as allowed whenever it's the
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
// is empty/absent (the Windows case). Video is still denied.
function isAudioCapturePermission(permission, details) {
if (permission === 'audioCapture') {
// breaks on Windows: Chromium frequently fires the request with an empty or
// undefined `mediaTypes`, so a strict check denies it and getUserMedia throws
// NotAllowedError. We therefore allow the capture permissions and treat absent
// metadata as allowed.
//
// Granting here is not the last gate: the OS still applies its own capture
// permission (macOS TCC prompts on first use, per the NSMicrophone/NSCamera
// usage strings), so the user keeps a real allow/deny and can revoke it in
// System Settings afterwards.
function isMediaCapturePermission(permission, details) {
if (permission === 'audioCapture' || permission === 'videoCapture') {
return true
}
@ -5529,38 +5657,31 @@ function isAudioCapturePermission(permission, details) {
const mediaTypes = details?.mediaTypes
// Windows: mediaTypes is often empty for a capture request. Don't deny on
// missing metadata.
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
// Windows: mediaTypes is often empty for a mic request. Don't deny on
// missing metadata. (A video request would carry mediaTypes:['video'].)
return true
}
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
return mediaTypes.includes('audio') || mediaTypes.includes('video')
}
function installMediaPermissions() {
// Async request handler: the prompt-style path (most platforms).
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
callback(isAudioCapturePermission(permission, details))
callback(isMediaCapturePermission(permission, details))
})
// Synchronous check handler: Chromium consults this for getUserMedia on
// Windows in addition to (or instead of) the request handler. Without it,
// the check defaults to false and the mic is denied before the request
// the check defaults to false and capture is denied before the request
// handler ever runs.
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
if (permission === 'media' || permission === ('audioCapture' as any) /* todo: is this needed? */) {
// details.mediaType is a single string here (not the mediaTypes array).
const mediaType = details?.mediaType
if (mediaType === 'video') {
return false
}
return true
}
return false
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
return (
permission === 'media' ||
permission === ('audioCapture' as any) /* todo: is this needed? */ ||
permission === ('videoCapture' as any)
)
})
}
@ -7933,6 +8054,27 @@ async function spawnPoolBackend(profile, entry) {
}
const token = crypto.randomBytes(32).toString('base64url')
// Same update mutual exclusion as the primary window's waitForLocalStart
// (#73822): pool backends spawn from the same venv, so an ungated respawn
// during applyUpdates' critical section re-locks the venv and trips the
// venv-blocker preflight. No boot-progress UI here — pool backends boot
// silently for background profiles — so we only log while parked.
{
let poolAnnounced = false
await waitForUpdateClearance(updateGateDeps(), {
onWaitTick: reason => {
if (!poolAnnounced) {
poolAnnounced = true
rememberLog(`[updates] update in progress (${reason}); deferring pool backend start for profile "${profile}"`)
}
},
pollMs: UPDATE_WAIT_POLL_MS,
timeoutMs: UPDATE_WAIT_TIMEOUT_MS
})
}
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
@ -8021,6 +8163,17 @@ async function spawnPoolBackend(profile, entry) {
entry.token = authToken
// Verify the WebSocket session token before declaring backend ready.
// HTTP /api/status can pass while WS auth fails (separate transport, separate guards).
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
if (!wsProbe.ok) {
throw new Error(
`Hermes backend for profile "${profile}" is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
)
}
return {
baseUrl,
mode: 'local',
@ -8028,7 +8181,7 @@ async function spawnPoolBackend(profile, entry) {
authMode: 'token',
token: authToken,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl,
logs: hermesLog.slice(-80),
...getWindowState()
}
@ -8346,6 +8499,16 @@ async function startHermes() {
rememberLog
})
// Verify the WebSocket session token before declaring backend ready.
const wsUrl = `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`
const wsProbe = await probeGatewayWebSocket(wsUrl, { WebSocketImpl: globalThis.WebSocket })
if (!wsProbe.ok) {
throw new Error(
`Local Hermes backend is HTTP-reachable but the WebSocket (/api/ws) rejected the session token: ${wsProbe.reason}`
)
}
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@ -8360,7 +8523,7 @@ async function startHermes() {
source: 'local',
authMode: 'token',
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl,
logs: hermesLog.slice(-80),
...getWindowState()
}
@ -9270,8 +9433,8 @@ ipcMain.handle('hermes:window:openInstance', async () => {
// shortcuts and the View menu. Reads and writes target the asking window.
ipcMain.handle('hermes:zoom:get', event => {
const window = BrowserWindow.fromWebContents(event.sender)
const level =
window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
const level = window && !window.isDestroyed() ? window.webContents.getZoomLevel() : DEFAULT_ZOOM_LEVEL
return { level, percent: zoomLevelToPercent(level) }
})
@ -10052,15 +10215,71 @@ ipcMain.handle('hermes:notify', (_event, payload) => {
return true
})
// Data-URL file load cap (composer attach + local previews). Main owns the
// persisted MB value so every IPC read honours Settings → Chat without the
// renderer having to pass maxBytes on each call. Default is 16 MB; clamp
// lives in hardening.ts.
const DATA_URL_READ_MAX_CONFIG_PATH = path.join(app.getPath('userData'), 'data-url-read-max.json')
function readPersistedDataUrlReadMaxMb() {
try {
return clampDataUrlReadMaxMb(JSON.parse(fs.readFileSync(DATA_URL_READ_MAX_CONFIG_PATH, 'utf8')).maxMb)
} catch {
return DATA_URL_READ_DEFAULT_MAX_MB
}
}
let dataUrlReadMaxMb = readPersistedDataUrlReadMaxMb()
function persistDataUrlReadMaxMb(maxMb) {
const next = clampDataUrlReadMaxMb(maxMb)
dataUrlReadMaxMb = next
try {
fs.mkdirSync(path.dirname(DATA_URL_READ_MAX_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DATA_URL_READ_MAX_CONFIG_PATH, JSON.stringify({ maxMb: next }, null, 2), 'utf8')
} catch (error) {
rememberLog(`[data-url-read-max] write failed: ${error.message}`)
}
return next
}
ipcMain.handle('hermes:data-url-read-max:get', () => ({
maxMb: dataUrlReadMaxMb,
// Keep the default bytes constant visible for tests / diagnostics.
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb)
}))
ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
const next = persistDataUrlReadMaxMb(maxMb)
return {
maxMb: next,
defaultMaxMb: DATA_URL_READ_DEFAULT_MAX_MB,
maxBytes: dataUrlReadMaxBytesFromMb(next)
}
})
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
const { resolvedPath } = await resolveReadableFileForIpc(filePath, {
maxBytes: DATA_URL_READ_MAX_BYTES,
return readFileDataUrlForIpc(filePath, {
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(filePath, { purpose: 'File preview' })),
purpose: 'File preview'
})
})
const data = await fs.promises.readFile(resolvedPath)
return `data:${mimeTypeForPath(resolvedPath)};base64,${data.toString('base64')}`
// Remote attachment transfer is independent of the preview / Settings path.
// Keep a finite cap so Electron + base64 memory stays bounded while archives
// can exceed the default 16 MiB preview ceiling (and still fit the gateway
// WebSocket frame limit after base64 expansion).
ipcMain.handle('hermes:readFileDataUrlForAttach', async (_event, filePath) => {
return readFileDataUrlForIpc(filePath, {
maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(filePath, { purpose: 'Attachment upload' })),
purpose: 'Attachment upload'
})
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
@ -10131,6 +10350,12 @@ ipcMain.handle('hermes:writeClipboard', (_event, text) => {
return true
})
// Paired reader for the GUI terminal's paste chord: the renderer's
// navigator.clipboard.readText() throws "Document is not focused" whenever a
// portaled overlay has focus, and there's no way to route a read through the
// canvas. The main process has no such gate.
ipcMain.handle('hermes:readClipboard', () => clipboard.readText())
ipcMain.handle('hermes:saveImageFromUrl', (_event, url) => saveImageFromUrl(String(url || '')))
ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
@ -10172,6 +10397,8 @@ ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
ipcMain.handle('hermes:watchPreviewFile', (_event, url) => watchPreviewFile(String(url || '')))
ipcMain.handle('hermes:watchDirectory', (_event, dir) => watchDirectory(String(dir || '')))
ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWatch(String(id || '')))
// Each renderer reports the turns it has in flight; the quit guard reads the
@ -10701,6 +10928,31 @@ ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
}
})
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
// the connected backend. A remote backend reports its own `hermes_home` over
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
// so it stays valid in every connection mode. Created on demand, like openDir.
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => {
// Profile-aware: a named Desktop profile gets its own plugin root under
// profiles/<name>/, matching the profile-scoped hermes_home the backend
// reported before this resolver existed. 'default'/unset pins the global root.
const profile = readActiveDesktopProfile()
const base = profile && profile !== 'default' ? path.join(HERMES_HOME, 'profiles', profile) : HERMES_HOME
const dir = path.join(base, 'desktop-plugins')
try {
await fs.promises.mkdir(dir, { recursive: true })
} catch {
// Best-effort create; return the path regardless so the reveal action can
// still surface a real openPath error and the scanner can retry later.
}
return dir
})
// Rename a file/folder in place. The renderer passes the existing path + a new
// base name; the destination is resolved in the SAME parent dir so a rename can
// never move the item elsewhere or traverse out. Rejects on a name collision.

View file

@ -98,9 +98,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
readFileDataUrlForAttach: filePath => ipcRenderer.invoke('hermes:readFileDataUrlForAttach', filePath),
dataUrlReadMax: {
get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'),
set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb)
},
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
@ -113,6 +119,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
},
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
watchDirectory: dir => ipcRenderer.invoke('hermes:watchDirectory', dir),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
@ -148,6 +155,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath),
desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'),
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),

View file

@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})
test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => {
// Regression: Chromium's default autoplay policy suspends audio until a user
// gesture. A wake-word-started voice conversation has no preceding click, so
// the first reply's playback was rejected and only turn 2+ spoke. A native
// app the user launched should not gate audio on a gesture.
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required')
})

View file

@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
//
// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
// and AudioContext) until the user has interacted with the frame. A voice
// conversation started by the "Hey Hermes" wake word has NO preceding click,
// so the FIRST reply's audio playback was rejected (NotAllowedError, silently
// swallowed) and only turn 2+ spoke — the very "first message in a new voice
// session is silent" bug. Manual voice-start worked only because the button
// click counted as the gesture. This is a native app the user deliberately
// launched; there is no drive-by-autoplay concern to protect against.
function chatWindowWebPreferences(preloadPath: string) {
return {
preload: preloadPath,
@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false
backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required' as const
}
}

View file

@ -0,0 +1,144 @@
/**
* Tests for electron/update-gate.ts the update mutual-exclusion gate that
* parks local backend spawns while an in-app update is running.
*
* The regression this guards (#73822): applyUpdates kills its own backend
* BEFORE the Windows venv-blocker scan but writes the on-disk marker AFTER
* it. A marker-only gate therefore let the renderer's reconnect spawn a
* fresh backend inside the update's own critical section, which the scan
* reported as a blocker aborting every Desktop update attempt on Windows.
* The gate must consult the in-process updateInFlight flag as well.
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { updateGateReason, waitForUpdateClearance } from './update-gate'
function deps(marker: boolean, inFlight: boolean) {
return {
hasLiveMarker: () => marker,
isUpdateInFlight: () => inFlight
}
}
// ---------------------------------------------------------------------------
// updateGateReason
// ---------------------------------------------------------------------------
test('gate open when neither marker nor flag is set', () => {
assert.equal(updateGateReason(deps(false, false)), null)
})
test('marker alone closes the gate', () => {
assert.equal(updateGateReason(deps(true, false)), 'marker')
})
test('updateInFlight alone closes the gate (#73822 — the pre-marker window)', () => {
assert.equal(updateGateReason(deps(false, true)), 'update-in-flight')
})
test('marker wins as the reported reason when both are set', () => {
assert.equal(updateGateReason(deps(true, true)), 'marker')
})
// ---------------------------------------------------------------------------
// waitForUpdateClearance
// ---------------------------------------------------------------------------
test('returns clear immediately without sleeping when the gate is open', async () => {
let slept = 0
const outcome = await waitForUpdateClearance(deps(false, false), {
pollMs: 10,
sleep: async () => {
slept += 1
},
timeoutMs: 1000
})
assert.equal(outcome, 'clear')
assert.equal(slept, 0)
})
test('parks on the in-flight flag and finishes when it clears', async () => {
// Simulates the #73822 sequence: the reconnect arrives while updateInFlight
// is true and no marker exists yet; the flag clears (abort path finally)
// and the waiter proceeds.
let inFlight = true
let ticks = 0
const outcome = await waitForUpdateClearance(
{ hasLiveMarker: () => false, isUpdateInFlight: () => inFlight },
{
onWaitTick: reason => {
ticks += 1
assert.equal(reason, 'update-in-flight')
if (ticks >= 3) {
inFlight = false
}
},
pollMs: 1,
sleep: async () => {},
timeoutMs: 10_000
}
)
assert.equal(outcome, 'finished')
assert.equal(ticks, 3)
})
test('parks across the flag→marker handoff without a gap', async () => {
// Success path: the marker is written (main.ts:2936) BEFORE applyUpdates'
// finally clears the flag, so a waiter that arrived during the scan stays
// parked through the transition instead of slipping through.
let inFlight = true
let marker = false
let ticks = 0
const reasons: string[] = []
const outcome = await waitForUpdateClearance(
{ hasLiveMarker: () => marker, isUpdateInFlight: () => inFlight },
{
onWaitTick: reason => {
ticks += 1
reasons.push(reason)
if (ticks === 2) {
marker = true // updater hand-off: marker written first…
}
if (ticks === 3) {
inFlight = false // …then the flag clears; marker still holds the gate
}
if (ticks === 5) {
marker = false // updater finished
}
},
pollMs: 1,
sleep: async () => {},
timeoutMs: 10_000
}
)
assert.equal(outcome, 'finished')
assert.deepEqual(reasons, ['update-in-flight', 'update-in-flight', 'marker', 'marker', 'marker'])
})
test('returns timeout when the gate never opens', async () => {
let clock = 0
const outcome = await waitForUpdateClearance(deps(true, false), {
now: () => clock,
pollMs: 10,
sleep: async ms => {
clock += ms
},
timeoutMs: 50
})
assert.equal(outcome, 'timeout')
})

View file

@ -0,0 +1,95 @@
'use strict'
/**
* update-gate.ts
*
* Pure, dependency-injected gate that parks local backend spawns while an
* in-app update is running (#73822, #50238).
*
* Two independent signals mean "an update owns the venv right now":
*
* - the on-disk marker (`HERMES_HOME/.hermes-update-in-progress`), written
* by the updater and by the desktop itself just before hand-off and
* - the in-process `updateInFlight` flag, true for the whole
* `applyUpdates()` critical section.
*
* The marker alone is NOT enough (#73822): `applyUpdates` kills its own
* backend early (`releaseBackendLock`) but only writes the marker AFTER the
* Windows venv-blocker scan. Killing the backend drops the renderer's
* WebSocket, the renderer reconnects within ~1s, and a marker-only gate
* happily spawns a fresh backend inside the update's own critical section
* which `scanVenvBlockers` then reports as a blocker, aborting every update
* attempt forever. Consulting the flag closes that window. On the success
* path the marker is written BEFORE the flag clears in `applyUpdates`'
* `finally`, so there is no instant where both signals are false and a
* waiter could slip through mid-update.
*/
export type UpdateGateReason = 'marker' | 'update-in-flight' | null
export interface UpdateGateDeps {
/** True when a live on-disk update marker exists (see update-marker.ts). */
hasLiveMarker: () => boolean
/** True while this process is inside applyUpdates()' critical section. */
isUpdateInFlight: () => boolean
}
/** Why the gate is closed right now, or null when it is open. */
export function updateGateReason(deps: UpdateGateDeps): UpdateGateReason {
if (deps.hasLiveMarker()) {
return 'marker'
}
if (deps.isUpdateInFlight()) {
return 'update-in-flight'
}
return null
}
export type UpdateClearanceOutcome = 'clear' | 'finished' | 'timeout'
export interface WaitForUpdateClearanceOptions {
timeoutMs: number
pollMs: number
/** Invoked once per poll while parked (boot progress / logging). */
onWaitTick?: (reason: Exclude<UpdateGateReason, null>) => void | Promise<void>
now?: () => number
sleep?: (ms: number) => Promise<void>
}
/**
* Park until no update signal remains, or the deadline passes.
*
* Returns 'clear' when the gate was already open (no wait happened),
* 'finished' when it opened during the wait, and 'timeout' when the deadline
* expired with the gate still closed (callers proceed anyway matching the
* long-standing marker-gate behavior, since a wedged updater must not brick
* the app forever).
*/
export async function waitForUpdateClearance(
deps: UpdateGateDeps,
options: WaitForUpdateClearanceOptions
): Promise<UpdateClearanceOutcome> {
const now = options.now || Date.now
const sleep = options.sleep || (ms => new Promise<void>(r => setTimeout(r, ms)))
let reason = updateGateReason(deps)
if (!reason) {
return 'clear'
}
const deadline = now() + options.timeoutMs
while (reason && now() < deadline) {
if (options.onWaitTick) {
await options.onWaitTick(reason)
}
await sleep(options.pollMs)
reason = updateGateReason(deps)
}
return reason ? 'timeout' : 'finished'
}

View file

@ -0,0 +1,218 @@
'use strict'
/**
* Tests for apps/desktop/electron/venv-blocker-scan.ts
*
* Run with: npx vitest run electron/venv-blocker-scan.test.ts
* (from apps/desktop; wired into npm test:desktop:platforms)
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { describe, it } from 'vitest'
import {
formatBlockerMessage,
formatProbeFailedMessage,
parseVenvBlockerScanOutput,
resolveVenvPython,
scanVenvBlockers
} from './venv-blocker-scan'
// ---------------------------------------------------------------------------
// resolveVenvPython
// ---------------------------------------------------------------------------
describe('resolveVenvPython', () => {
it('returns a real path when a temp venv python file exists', () => {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-vt-'))
try {
const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin'
const pythonName = process.platform === 'win32' ? 'python.exe' : 'python3'
const dir = path.join(sandbox, 'venv', scriptsDir)
fs.mkdirSync(dir, { recursive: true })
const pyPath = path.join(dir, pythonName)
fs.writeFileSync(pyPath, '', { mode: 0o755 })
assert.equal(resolveVenvPython(sandbox), pyPath)
} finally {
fs.rmSync(sandbox, { recursive: true, force: true })
}
})
it('returns null for non-existent venv', () => {
assert.equal(resolveVenvPython('/nonexistent'), null)
})
})
// ---------------------------------------------------------------------------
// formatBlockerMessage / formatProbeFailedMessage
// ---------------------------------------------------------------------------
describe('formatBlockerMessage', () => {
it('includes PID, name, cmdline, remote-client warning, and retry suggestion', () => {
const msg = formatBlockerMessage({
blocked: true,
processes: [{ pid: 101, name: 'python.exe', cmdline: 'serve --host 10.0.0.1' }]
})
assert.ok(msg.includes('PID 101'))
assert.ok(msg.includes('python.exe'))
assert.ok(msg.includes('serve'))
assert.ok(msg.includes('remote backend'))
assert.ok(msg.includes('retry'))
assert.ok(!msg.includes('force-venv'))
})
})
describe('formatProbeFailedMessage', () => {
it('suggests retry and hermes update', () => {
const msg = formatProbeFailedMessage()
assert.ok(msg.includes('hermes update'))
assert.ok(msg.includes('retry'))
})
})
// ---------------------------------------------------------------------------
// parseVenvBlockerScanOutput — pure function
// ---------------------------------------------------------------------------
describe('parseVenvBlockerScanOutput', () => {
const ok = (over: any = {}) => JSON.stringify({ ok: true, blocked: false, processes: [], ...over })
it('valid clear', () => {
const o = parseVenvBlockerScanOutput(ok())
assert.equal(o.kind, 'clear')
})
it('valid blocked', () => {
const o = parseVenvBlockerScanOutput(
ok({
blocked: true,
processes: [{ pid: 1, name: 'p', cmdline: 'c' }]
})
)
assert.equal(o.kind, 'blocked')
})
it('malformed JSON', () => {
assert.equal(parseVenvBlockerScanOutput('not json').kind, 'probe-failure')
})
it('ok=false is rejected', () => {
assert.equal(
parseVenvBlockerScanOutput(JSON.stringify({ ok: false, blocked: false, processes: [] })).kind,
'probe-failure'
)
})
it('blocked must be boolean', () => {
assert.equal(parseVenvBlockerScanOutput(ok({ blocked: 'false' })).kind, 'probe-failure')
})
it('blocked=true with empty processes rejected', () => {
assert.equal(parseVenvBlockerScanOutput(ok({ blocked: true, processes: [] })).kind, 'probe-failure')
})
it('blocked=false with non-empty processes rejected', () => {
assert.equal(
parseVenvBlockerScanOutput(ok({ processes: [{ pid: 1, name: 'p', cmdline: 'c' }] })).kind,
'probe-failure'
)
})
it('process pid must be positive integer', () => {
assert.equal(
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 0, name: 'p', cmdline: 'c' }] })).kind,
'probe-failure'
)
})
it('process name must be non-empty string', () => {
assert.equal(
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: '', cmdline: 'c' }] })).kind,
'probe-failure'
)
})
it('process missing cmdline is rejected', () => {
assert.equal(
parseVenvBlockerScanOutput(ok({ blocked: true, processes: [{ pid: 1, name: 'p' }] })).kind,
'probe-failure'
)
})
})
// ---------------------------------------------------------------------------
// scanVenvBlockers — subprocess with injection
// ---------------------------------------------------------------------------
describe('scanVenvBlockers', () => {
const stubVenv = () => '/fake/venv/python.exe'
const okJson = JSON.stringify({ ok: true, blocked: false, processes: [] })
const blockedJson = JSON.stringify({
ok: true,
blocked: true,
processes: [{ pid: 1, name: 'p', cmdline: 'c' }]
})
function execReturn(json: string): any {
return (async (...args: any[]) => ({ stdout: json, stderr: '' })) as any
}
function execThrow(status: number, stderr: string): any {
return (async (...args: any[]) => {
const e: any = new Error()
e.status = status
e.stderr = Buffer.from(stderr)
throw e
}) as any
}
it('clear scan returns clear', async () => {
assert.equal((await scanVenvBlockers('/r', execReturn(okJson), stubVenv)).kind, 'clear')
})
it('blocked scan returns blocked', async () => {
assert.equal((await scanVenvBlockers('/r', execReturn(blockedJson), stubVenv)).kind, 'blocked')
})
it('non-zero exit is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execThrow(2, 'ModuleNotFoundError'), stubVenv)
assert.equal(o.kind, 'probe-failure')
})
it('missing venv python is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execReturn(okJson), () => null)
assert.equal(o.kind, 'probe-failure')
})
it('malformed subprocess output is probe-failure', async () => {
const o = await scanVenvBlockers('/r', execReturn('bad json'), stubVenv)
assert.equal(o.kind, 'probe-failure')
})
it('calls subprocess with correct args, cwd and timeout', async () => {
const calls: any[] = []
const spy = (async (cmd: string, args: string[], opts: any) => {
calls.push({ cmd, args, cwd: opts.cwd, timeout: opts.timeout })
return { stdout: okJson, stderr: '' }
}) as any
await scanVenvBlockers('/update/root', spy, stubVenv)
assert.equal(calls.length, 1)
const c = calls[0]
assert.ok(c.cmd.endsWith('python.exe'))
assert.deepEqual(c.args, ['-m', 'hermes_cli._scan_venv_blockers'])
assert.equal(c.cwd, '/update/root')
assert.equal(typeof c.timeout, 'number')
assert.ok(c.timeout > 0)
})
})

View file

@ -0,0 +1,214 @@
'use strict'
/**
* venv-blocker-scan.ts
*
* Thin helper that runs the Python venv-blocker scan as a subprocess and
* returns a typed result for the Desktop update preflight.
*/
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VenvBlockerProcess {
pid: number
name: string
cmdline: string
}
export interface VenvBlockerScanResult {
blocked: boolean
processes: VenvBlockerProcess[]
}
export type ScanOutcome =
| { kind: 'clear'; result: VenvBlockerScanResult }
| { kind: 'blocked'; result: VenvBlockerScanResult }
| { kind: 'probe-failure'; error: string }
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SCAN_TIMEOUT_MS = 15000
const SCAN_MODULE = 'hermes_cli._scan_venv_blockers'
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Strictly validate and parse the JSON output from the venv-blocker scan.
* Pure function no side effects.
*/
export function parseVenvBlockerScanOutput(raw: string): ScanOutcome {
let parsed: any
try {
parsed = JSON.parse(raw)
} catch {
return { kind: 'probe-failure', error: 'malformed JSON' }
}
if (!parsed || typeof parsed !== 'object' || parsed.ok !== true) {
return { kind: 'probe-failure', error: 'missing or invalid ok field' }
}
if (typeof parsed.blocked !== 'boolean') {
return { kind: 'probe-failure', error: 'blocked must be a boolean' }
}
if (!Array.isArray(parsed.processes)) {
return { kind: 'probe-failure', error: 'processes must be an array' }
}
const processes: VenvBlockerProcess[] = []
for (const entry of parsed.processes) {
if (!entry || typeof entry !== 'object') {
return { kind: 'probe-failure', error: 'process entry must be an object' }
}
const { pid, name, cmdline } = entry
if (!Number.isInteger(pid) || pid <= 0) {
return { kind: 'probe-failure', error: 'process pid must be a positive integer' }
}
if (typeof name !== 'string' || name.length === 0) {
return { kind: 'probe-failure', error: 'process name must be a non-empty string' }
}
if (typeof cmdline !== 'string') {
return { kind: 'probe-failure', error: 'process cmdline must be a string' }
}
processes.push({ pid, name, cmdline })
}
// Reject inconsistent combinations
if (parsed.blocked && processes.length === 0) {
return { kind: 'probe-failure', error: 'blocked is true but process list is empty' }
}
if (!parsed.blocked && processes.length > 0) {
return { kind: 'probe-failure', error: 'blocked is false but process list is non-empty' }
}
return parsed.blocked
? { kind: 'blocked', result: { blocked: true, processes } }
: { kind: 'clear', result: { blocked: false, processes } }
}
/**
* Run the venv-blocker scan subprocess. Async so the Electron main-process
* event loop is never blocked by the psutil process scan (up to 15s on a
* loaded Windows box). Accepts optional overrides for testing (dependency
* injection).
*/
export async function scanVenvBlockers(
updateRoot: string,
execOverride?: typeof execFileAsync,
resolveOverride?: typeof resolveVenvPython
): Promise<ScanOutcome> {
const execFn = execOverride || execFileAsync
const resolveFn = resolveOverride || resolveVenvPython
const venvPython = resolveFn(updateRoot)
if (!venvPython) {
return { kind: 'probe-failure', error: 'venv python not found' }
}
let stdout: string
try {
const proc = await execFn(venvPython, ['-m', SCAN_MODULE], {
cwd: updateRoot,
encoding: 'utf-8',
timeout: SCAN_TIMEOUT_MS,
windowsHide: true
} as any)
stdout = String((proc as any).stdout ?? '')
} catch (err: any) {
const diag = [`exit code ${err.status ?? err.code ?? -1}`]
if (err.stderr) {
diag.push(String(err.stderr).slice(0, 200))
}
return { kind: 'probe-failure', error: diag.join('; ') }
}
return parseVenvBlockerScanOutput(stdout)
}
// ---------------------------------------------------------------------------
// Internal helpers (exported for testing)
// ---------------------------------------------------------------------------
/** Resolve the venv python path. Returns null if the file does not exist. */
export function resolveVenvPython(updateRoot: string): string | null {
const isWindows = process.platform === 'win32'
const pythonName = isWindows ? 'python.exe' : 'python3'
const scriptsDir = isWindows ? 'Scripts' : 'bin'
const candidate = path.join(updateRoot, 'venv', scriptsDir, pythonName)
try {
fs.accessSync(candidate)
return candidate
} catch {
return null
}
}
/**
* Build a human-readable error message from blocker scan results.
* Does NOT recommend --force-venv.
*/
export function formatBlockerMessage(result: VenvBlockerScanResult): string {
const lines = [
'Update aborted: another Hermes process is using this installation.',
'',
'These processes must be stopped before updating:',
''
]
for (const proc of result.processes.slice(0, 10)) {
lines.push(` PID ${proc.pid} ${proc.name} ${proc.cmdline}`)
}
if (result.processes.length > 10) {
lines.push(` ... and ${result.processes.length - 10} more`)
}
lines.push('')
lines.push(
'Close the terminal, app, or service owning that process. If it is a ' +
'remote backend, stopping it will disconnect remote clients.'
)
lines.push('Then retry the update.')
return lines.join('\n')
}
/**
* Build a probe-failure error message.
*/
export function formatProbeFailedMessage(): string {
return (
'Update aborted: Desktop could not verify the Hermes installation is free.\n' +
'\n' +
'Close other Hermes windows and terminals, then retry. If the problem\n' +
'persists, run `hermes update` in a terminal for detailed diagnostics.'
)
}

View file

@ -220,6 +220,7 @@
"CFBundleExecutable": "Hermes",
"CFBundleName": "Hermes",
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
"NSCameraUsageDescription": "Hermes uses the camera when a plugin or feature you enable requests it.",
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
},
"gatekeeperAssess": false,

View file

@ -0,0 +1,61 @@
#!/usr/bin/env node
// Robust A/B: measure each surface N times, report median wasted renders.
// Reduces timing noise from stream ticks landing on different clicks.
import WebSocket from 'ws'
const RUNS = 3
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const h = (data) => { const m = JSON.parse(data); if (m.id === id) { ws.off('message', h); m.error ? reject(m.error) : resolve(m.result) } }
ws.on('message', h); ws.send(JSON.stringify({ id, method, params }))
})
}
async function ev(ws, e) { return (await send(ws, 'Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }))?.result?.value }
const sleep = ms => new Promise(r => setTimeout(r, ms))
const median = arr => { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)] }
async function wsUrl() {
const d = await (await fetch('http://127.0.0.1:9222/json/list')).json()
return d.find(t => t.type === 'page' && (t.url||'').includes('5174')).webSocketDebuggerUrl
}
async function measureOnce(ws, setup, holdMs) {
await setup(ws)
await sleep(300)
await ev(ws, `__RENDER_COUNTS__.start(); true`)
await sleep(holdMs)
const rep = JSON.parse(await ev(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
return rep.reduce((s, c) => s + c.wasted, 0)
}
async function main() {
const ws = new WebSocket(await wsUrl())
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
await send(ws, 'Runtime.enable')
const surfaces = [
['Artifacts', ws => ev(ws, `window.location.hash='#/artifacts'; true`), 2000],
['Messaging', ws => ev(ws, `window.location.hash='#/messaging'; true`), 2000],
['Cron', ws => ev(ws, `window.location.hash='#/cron'; true`), 2000],
['Profiles', ws => ev(ws, `window.location.hash='#/profiles'; true`), 2000],
['Agents', ws => ev(ws, `window.location.hash='#/agents'; true`), 2000],
['Starmap', ws => ev(ws, `window.location.hash='#/starmap'; true`), 2000],
['Webhooks', ws => ev(ws, `window.location.hash='#/webhooks'; true`), 2000],
['CommandCenter/System', async ws => { await ev(ws, `window.location.hash='#/command-center'; true`); await sleep(400); await ev(ws, `Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='System')?.click(); true`) }, 2000],
]
const label = process.argv[2] || 'RUN'
console.log(`\n=== ${label} (median of ${RUNS}, wasted renders, 2s idle) ===`)
for (const [name, setup, hold] of surfaces) {
const runs = []
for (let i = 0; i < RUNS; i++) {
runs.push(await measureOnce(ws, setup, hold))
await ev(ws, `window.location.hash='#/'; true`); await sleep(300)
}
console.log(` ${name.padEnd(24)} median ${String(median(runs)).padStart(6)} (runs: ${runs.join(', ')})`)
}
ws.close()
}
main().catch(e => { console.error(e); process.exit(1) })

View file

@ -0,0 +1,161 @@
#!/usr/bin/env node
// CDP probe: measure render churn on overlay surfaces (cmdk, settings, command-center, skills)
// against the user's live instance on :9222. Read-only — never closes or navigates away.
import WebSocket from 'ws'
const CDP_URL = 'ws://127.0.0.1:9222'
const TARGET_GLOB = '/devtools/page/'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function getRendererTarget() {
const resp = await fetch('http://127.0.0.1:9222/json/list')
const targets = await resp.json()
// Find the renderer target (not devtools)
return targets.find(t => t.url?.startsWith('http://127.0.0.1:5174') || t.url?.includes('5174'))
?? targets.find(t => t.type === 'page' && !t.url.startsWith('devtools'))
}
async function evalInPage(ws, expr) {
const result = await send(ws, 'Runtime.evaluate', {
expression: expr,
returnByValue: true,
awaitPromise: true,
})
return result?.result?.value
}
async function main() {
const target = await getRendererTarget()
if (!target) {
console.error('No renderer target found')
process.exit(1)
}
console.log(`Target: ${target.url}`)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
await send(ws, 'Runtime.enable')
// Check if __RENDER_COUNTS__ is available
const hasCounter = await evalInPage(ws, 'typeof __RENDER_COUNTS__')
console.log(`__RENDER_COUNTS__ available: ${hasCounter}`)
if (hasCounter !== 'object') {
console.log('Render counter not loaded — checking perf-live...')
const hasPerfLive = await evalInPage(ws, 'typeof __PERF_LIVE__')
console.log(`__PERF_LIVE__ available: ${hasPerfLive}`)
}
// 1. Baseline: open cmdk, type a few chars, measure
console.log('\n=== CmdK Palette ===')
await evalInPage(ws, `
window.__renderBaseline = {}
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Open cmdk via keyboard shortcut
const evt = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })
document.dispatchEvent(evt)
true
`)
await new Promise(r => setTimeout(r, 500))
// Type some characters
for (const ch of ['s', 'e', 't', 't', 'i', 'n', 'g']) {
await evalInPage(ws, `
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[placeholder]')
if (el) {
el.focus()
el.value = el.value + '${ch}'
el.dispatchEvent(new Event('input', { bubbles: true }))
}
true
`)
await new Promise(r => setTimeout(r, 80))
}
await new Promise(r => setTimeout(r, 300))
const cmdkCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('CmdK render counts after typing "setting":', JSON.stringify(cmdkCounts, null, 2))
// Close cmdk
await evalInPage(ws, `new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`)
await new Promise(r => setTimeout(r, 300))
// 2. Command Center — measure renders while on System tab
console.log('\n=== Command Center (System tab) ===')
await evalInPage(ws, `
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Navigate to command center system section
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('System') || el.textContent?.includes('Command Center'))
if (link) link.click()
true
`)
await new Promise(r => setTimeout(r, 1000))
const ccCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('Command Center render counts (after 1s on System tab):', JSON.stringify(ccCounts, null, 2))
// 3. Settings page — measure renders on nav
console.log('\n=== Settings Page ===')
await evalInPage(ws, `
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Navigate to settings
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('Settings'))
if (link) link.click()
true
`)
await new Promise(r => setTimeout(r, 1000))
// Click through a few nav items
for (const label of ['Appearance', 'Gateway', 'Keys', 'About']) {
await evalInPage(ws, `
const el = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '${label}')
if (el) el.click()
true
`)
await new Promise(r => setTimeout(r, 150))
}
const settingsCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('Settings render counts (after clicking 4 nav items):', JSON.stringify(settingsCounts, null, 2))
ws.close()
console.log('\nDone.')
}
main().catch(err => {
console.error('Error:', err)
process.exit(1)
})

View file

@ -0,0 +1,201 @@
#!/usr/bin/env node
// Comprehensive overlay render-churn measurement on the live instance.
// Measures: cmdk root + submenus, settings, command center, capabilities, system overlays.
// Read-only — never closes the app, only navigates and clicks.
import WebSocket from 'ws'
const WS_URL = 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function eval_(ws, expr) {
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
return r?.result?.value
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function measure(ws, label, fn, settleMs = 400) {
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
await fn(ws)
await sleep(settleMs)
const report = await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`)
const parsed = JSON.parse(report)
const totalRenders = parsed.reduce((s, c) => s + c.renders, 0)
const totalWasted = parsed.reduce((s, c) => s + c.wasted, 0)
const totalMs = parsed.reduce((s, c) => s + c.totalMs, 0)
const top5 = parsed.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 5)
console.log(`\n${label}`)
console.log(` total: ${totalRenders} renders, ${totalWasted} wasted, ${totalMs.toFixed(1)}ms`)
if (top5.length > 0) {
for (const c of top5) {
console.log(` ${c.name}: ${c.renders} renders, ${c.wasted} wasted, ${c.totalMs.toFixed(1)}ms`)
}
} else {
console.log(` (no wasted renders)`)
}
return { label, totalRenders, totalWasted, totalMs, top: top5 }
}
async function clickByText(ws, text, tag = 'button') {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === '${text}')
if (el) { el.click(); true } else false
`)
}
async function clickByHref(ws, partial) {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('a,button')).find(e => e.getAttribute('href')?.includes('${partial}'))
if (el) { el.click(); true } else false
`)
}
async function typeInInput(ws, text) {
for (const ch of text) {
await eval_(ws, `
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[type="text"]') || document.querySelector('input')
if (el) { el.focus(); el.value = el.value + '${ch}'; el.dispatchEvent(new Event('input', {bubbles:true})) }
true
`)
await sleep(50)
}
}
async function pressKey(ws, key, mods = {}) {
await eval_(ws, `
document.dispatchEvent(new KeyboardEvent('keydown', { key: '${key}', ${Object.entries(mods).map(([k,v]) => `${k}:${v}`).join(', ')}, bubbles: true }))
true
`)
}
async function main() {
const ws = new WebSocket(WS_URL)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
await send(ws, 'Runtime.enable')
const avail = await eval_(ws, `typeof __RENDER_COUNTS__`)
console.log(`__RENDER_COUNTS__: ${avail}`)
if (avail !== 'object') {
console.error('Render counter not loaded. Reload the page.')
process.exit(1)
}
const results = []
// === 1. CmdK root: open, type "setting", close ===
results.push(await measure(ws, '1. CmdK root (type "setting")', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'setting')
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 2. CmdK submenu: open, navigate to theme picker, click a theme, back ===
results.push(await measure(ws, '2. CmdK theme submenu', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'theme')
await sleep(200)
// Click first theme item
await eval_(ws, `
const item = document.querySelector('[cmdk-item]')
if (item) item.click()
true
`)
await sleep(300)
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 3. CmdK color-mode submenu ===
results.push(await measure(ws, '3. CmdK color-mode submenu', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'color mode')
await sleep(200)
await eval_(ws, `const item = document.querySelector('[cmdk-item]'); if (item) item.click(); true`)
await sleep(300)
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 4. Settings: open, click through nav items ===
results.push(await measure(ws, '4. Settings (5 nav clicks)', async (ws) => {
await clickByHref(ws, 'settings')
await sleep(500)
for (const label of ['Appearance', 'Gateway', 'Keys', 'Notifications', 'About']) {
await clickByText(ws, label)
await sleep(100)
}
}))
// === 5. Command Center: open to System tab, idle 2s ===
results.push(await measure(ws, '5. Command Center (System tab, 2s idle)', async (ws) => {
await clickByHref(ws, 'command-center')
await sleep(500)
await clickByText(ws, 'System')
await sleep(2000)
}, 100))
// === 6. Command Center: Sessions tab ===
results.push(await measure(ws, '6. Command Center (Sessions tab, 1s)', async (ws) => {
await clickByText(ws, 'Sessions')
await sleep(1000)
}, 100))
// === 7. Capabilities/Skills page ===
results.push(await measure(ws, '7. Capabilities (Skills tab, 1s)', async (ws) => {
await clickByHref(ws, 'skills')
await sleep(800)
// Click Skills tab if not already
await clickByText(ws, 'Skills')
await sleep(1000)
}, 100))
// === 8. Capabilities Toolsets tab ===
results.push(await measure(ws, '8. Capabilities (Toolsets tab, 1s)', async (ws) => {
await clickByText(ws, 'Tools')
await sleep(1000)
}, 100))
// === 9. Capabilities MCP tab ===
results.push(await measure(ws, '9. Capabilities (MCP tab, 1s)', async (ws) => {
await clickByText(ws, 'MCP')
await sleep(1000)
}, 100))
// Navigate back to chat
await eval_(ws, `window.location.hash = '#/'; true`)
await sleep(300)
// Summary table
console.log('\n\n=== SUMMARY ===')
console.log('Surface'.padEnd(45) + 'Renders'.padStart(10) + 'Wasted'.padStart(10) + 'ms'.padStart(10))
console.log('-'.repeat(75))
for (const r of results) {
console.log(r.label.padEnd(45) + String(r.totalRenders).padStart(10) + String(r.totalWasted).padStart(10) + r.totalMs.toFixed(1).padStart(10))
}
ws.close()
}
main().catch(err => { console.error(err); process.exit(1) })

View file

@ -0,0 +1,110 @@
#!/usr/bin/env node
// Full surface sweep: every overlay, every settings sub-page, every system overlay.
// Read-only — navigates and clicks, never closes the app.
import WebSocket from 'ws'
const WS_URL = process.env.CDP_WS || 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function eval_(ws, expr) {
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
return r?.result?.value
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function measure(ws, label, fn, settleMs = 300) {
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
await fn(ws)
await sleep(settleMs)
const report = JSON.parse(await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
const totalRenders = report.reduce((s, c) => s + c.renders, 0)
const totalWasted = report.reduce((s, c) => s + c.wasted, 0)
const totalMs = report.reduce((s, c) => s + c.totalMs, 0)
const top = report.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 6)
console.log(`\n${label}`)
console.log(` ${totalRenders} renders · ${totalWasted} wasted · ${totalMs.toFixed(1)}ms`)
for (const c of top) console.log(` ${c.name}: ${c.renders}r / ${c.wasted}w / ${c.totalMs.toFixed(1)}ms`)
if (top.length === 0) console.log(` (clean)`)
return { label, totalRenders, totalWasted, totalMs }
}
async function nav(ws, hash) {
await eval_(ws, `window.location.hash = '#${hash}'; true`)
}
async function clickText(ws, text, tag='button') {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === ${JSON.stringify(text)})
if (el) { el.click(); true } else false
`)
}
async function main() {
const ws = new WebSocket(WS_URL)
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
await send(ws, 'Runtime.enable')
if (await eval_(ws, `typeof __RENDER_COUNTS__`) !== 'object') { console.error('counter not loaded'); process.exit(1) }
const results = []
// System overlays — open each, idle 2s (streaming underneath is the churn test)
for (const [label, route] of [
['Artifacts', '/artifacts'],
['Messaging', '/messaging'],
['Cron', '/cron'],
['Profiles', '/profiles'],
['Agents', '/agents'],
['Starmap', '/starmap'],
['Webhooks', '/webhooks'],
]) {
results.push(await measure(ws, `OVERLAY: ${label} (open + 2s idle)`, async (ws) => {
await nav(ws, route)
await sleep(2200)
}, 100))
await nav(ws, '/')
await sleep(300)
}
// Settings sub-pages — open settings, click each nav item, idle 1.5s on it
await nav(ws, '/settings')
await sleep(600)
for (const label of ['Model','Session','Appearance','Notifications','Providers','Gateway','Keybinds','API Keys','Plugins','Archived Chats','About']) {
results.push(await measure(ws, `SETTINGS: ${label} (1.5s idle)`, async (ws) => {
await clickText(ws, label)
await sleep(1500)
}, 100))
}
await nav(ws, '/')
await sleep(300)
// Messaging sub-tabs (platform detail) — click through if present
await nav(ws, '/messaging')
await sleep(800)
results.push(await measure(ws, 'MESSAGING: idle 2s w/ platform list', async (ws) => {
await sleep(2000)
}, 100))
await nav(ws, '/')
await sleep(300)
console.log('\n\n=== SUMMARY (wasted renders) ===')
console.log('Surface'.padEnd(48) + 'Renders'.padStart(9) + 'Wasted'.padStart(9) + 'ms'.padStart(9))
console.log('-'.repeat(75))
for (const r of results) {
console.log(r.label.padEnd(48) + String(r.totalRenders).padStart(9) + String(r.totalWasted).padStart(9) + r.totalMs.toFixed(1).padStart(9))
}
ws.close()
}
main().catch(e => { console.error(e); process.exit(1) })

View file

@ -1,5 +1,5 @@
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ZoomableImage } from '@/components/chat/zoomable-image'
@ -96,7 +96,7 @@ type CellCtx = {
}
interface ArtifactColumn {
Cell: (props: { artifact: ArtifactRecord; ctx: CellCtx }) => React.ReactElement
Cell: React.ComponentType<{ artifact: ArtifactRecord; ctx: CellCtx }>
bodyClassName: string
header: (filter: ArtifactFilter, a: Translations['artifacts']) => string
id: 'location' | 'primary' | 'session'
@ -278,10 +278,12 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
})
}, [])
const cellCtx: CellCtx = {
onOpen: openArtifact,
onOpenChat: sessionId => openSession(sessionId, navigate)
}
// Stable ctx: recreating it (or its onOpenChat closure) every render made
// every artifact cell re-render whenever the page did — and a link cell's
// async title fetch re-rendered the page repeatedly. openArtifact is already
// a useCallback; navigate is stable, so onOpenChat can be too.
const openChat = useCallback((sessionId: string) => openSession(sessionId, navigate), [navigate])
const cellCtx: CellCtx = useMemo(() => ({ onOpen: openArtifact, onOpenChat: openChat }), [openArtifact, openChat])
return (
<PageSearchShell
@ -549,7 +551,7 @@ function ArtifactCellAction({
)
}
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const PrimaryCell = memo(function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const isLink = artifact.kind === 'link'
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
const Icon = brand ?? (isLink ? Link2 : FileText)
@ -571,9 +573,9 @@ function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
</span>
</ArtifactCellAction>
)
}
})
function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const LocationCell = memo(function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const { t } = useI18n()
const isLink = artifact.kind === 'link'
const value = isLink ? hostPathLabel(artifact.value) : artifact.value
@ -602,9 +604,9 @@ function LocationCell({ artifact }: { artifact: ArtifactRecord; ctx: CellCtx })
/>
</div>
)
}
})
function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const SessionCell = memo(function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
return (
<ArtifactCellAction onClick={() => ctx.onOpenChat(artifact.sessionId)} title={artifact.sessionTitle}>
<span className="flex min-w-0 flex-col">
@ -615,7 +617,7 @@ function SessionCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx
</span>
</ArtifactCellAction>
)
}
})
const ARTIFACT_COLUMNS: readonly ArtifactColumn[] = [
{

View file

@ -3,13 +3,16 @@
* through the SAME registry schema as every other surface (statusbar, titlebar,
* panes, layouts):
*
* render areas (`render`): composer.top banner strip above the input
* composer.bottom row below the input grid
* composer.leading inline after the "+" menu
* composer.actions inline before the model pill
* render areas (`render`): composer.top banner strip above the input
* composer.bottom row below the input grid
* composer.underside floating strip BELOW the
* whole composer (no chrome)
* composer.leading inline after the "+" menu
* composer.actions inline before the model pill
*
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
* data kinds (`data`): composer.middleware (ComposerMiddleware)
* composer.attachments (ComposerAttachmentProvider)
* composer.microActions (ComposerMicroActionProvider)
*
* Core keeps ownership of the transcript, input, and submit engine these
* seams AUGMENT the composer, they never replace it. Middleware runs as an
@ -17,17 +20,23 @@
* draft, pass it through, or cancel the send by returning null.
*/
import { useMemo } from 'react'
import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import type { TodoItem } from '@/lib/todos'
import type { ComposerAttachment } from '@/store/composer'
import type { ComposerAction } from '@/store/composer-actions'
export const COMPOSER_AREAS = {
top: 'composer.top',
bottom: 'composer.bottom',
underside: 'composer.underside',
leading: 'composer.leading',
actions: 'composer.actions',
middleware: 'composer.middleware',
attachments: 'composer.attachments'
attachments: 'composer.attachments',
microActions: 'composer.microActions'
} as const
export interface ComposerDraft {
@ -92,3 +101,39 @@ export function useComposerAttachmentProviders(): Array<ComposerAttachmentProvid
.map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
.filter(p => Boolean(p.label && p.run))
}
/**
* Payload of a `composer.microActions` data contribution the pill strip at
* the top of the composer's overlay lane.
*
* `resolve` is called with the live session context and returns the badges to
* show right now, or `[]` for "nothing from me". Returning a list rather than
* a static badge is what lets a provider be conditional ("only while idle",
* "only with unfinished tasks") without a reactive `when()`, which the
* registry deliberately doesn't offer.
*/
export interface ComposerMicroActionProvider {
resolve: (ctx: ComposerMicroActionContext) => ComposerAction[]
}
/** What a micro-action provider gets to branch on. Deliberately small: every
* field here is a standing compatibility promise to the plugins using it. */
export interface ComposerMicroActionContext {
/** A turn is currently running in this session. */
busy: boolean
sessionId: string
/** Live todo list for the session (empty when there is none). */
todos: readonly TodoItem[]
}
/** Micro-action providers, memoised against the registry's own stable
* snapshot the strip re-resolves on every composer render, so a fresh array
* here would defeat that. */
export function useComposerMicroActionProviders(): ComposerMicroActionProvider[] {
const contributions = useContributions(COMPOSER_AREAS.microActions)
return useMemo(
() => contributions.map(c => c.data as ComposerMicroActionProvider).filter(p => typeof p?.resolve === 'function'),
[contributions]
)
}

View file

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
@ -77,3 +78,62 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
describe('wake-word ear visibility', () => {
afterEach(() => {
resetWakeWordState()
})
it('stays mounted during a busy agent turn', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
})
it('stays mounted (enabled in config) even when a start was refused', () => {
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
// Transient refusal marks available false but enabled keeps it mounted.
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
renderControls()
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('stays visible (never hides) even when unavailable and not enabled', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
renderControls()
// The ear ALWAYS shows so the user can click to enable; a failed start
// surfaces its reason in the tooltip rather than hiding the control.
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('surfaces the backend refusal reason in the tooltip, still visible', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false })
renderControls()
const ear = screen.getByLabelText('Wake word: "hey hermes" — off')
expect(ear).toBeTruthy()
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({
conversation: {
active: true,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'listening'
}
})
const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat')
expect((ear as HTMLButtonElement).disabled).toBe(true)
})
})

View file

@ -1,10 +1,24 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import {
AudioLines,
Ear,
EarOff,
iconSize,
Layers3,
Loader2,
Square,
SteeringWheel,
Volume2,
VolumeX
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
@ -80,6 +94,7 @@ export function ComposerControls({
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
@ -181,6 +196,9 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
{/* Keep the ear visible during voice chat shown paused, since the
conversation holds the mic (the one time wake must not listen). */}
<WakeWordButton disabled={disabled} pausedForVoice />
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
@ -294,6 +312,55 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
)
}
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
// user must always be able to click it to turn passive listening on; if the
// backend can't start (missing STT/TTS, deps still installing, no mic
// permission, etc.) the click surfaces the reason in the tooltip and the
// toggle stays off. States: listening (accent-highlighted), off (muted
// ear-off), and paused-for-voice (disabled while a voice conversation holds
// the mic — the one time wake genuinely must not listen). Backend refusals
// ({started:false, reason}) keep the toggle off and put the reason/hint in
// the tooltip.
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const label = pausedForVoice
? c.wakeWordPausedVoice(phrase)
: wake.listening
? c.wakeWordListening(phrase)
: c.wakeWordOff(phrase)
const tooltip = !pausedForVoice && wake.notice ? `${label}${wake.notice}` : label
return (
<Tip label={tooltip}>
<Button
aria-label={label}
aria-pressed={wake.listening && !pausedForVoice}
className={cn(
GHOST_ICON_BTN,
'p-0',
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
void toggleWakeWord()
}}
size="icon"
type="button"
variant="ghost"
>
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
</Button>
</Tip>
)
}
function DictationButton({
disabled,
state,

View file

@ -1,6 +1,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { blurComposerInput } from './focus'
import {
blurComposerInput,
getActiveComposer,
markActiveComposer,
onComposerFocusRequest,
releaseActiveComposer,
requestComposerFocus
} from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
/**
@ -21,8 +28,23 @@ function mountInput(hidden = false) {
return input
}
/** A chat surface stamp — the same `data-composer-target` ChatView hangs. */
function mountSurface(target: string, hidden = false) {
const layer = document.createElement('div')
layer.toggleAttribute('data-pane-hidden', hidden)
const surface = document.createElement('div')
surface.dataset.composerTarget = target
layer.append(surface)
document.body.append(layer)
return surface
}
afterEach(() => {
document.body.innerHTML = ''
// `activeTarget` is module-level — a case that leaves a stale claim behind
// would otherwise decide the next one.
markActiveComposer('main')
})
describe('blurComposerInput', () => {
@ -48,3 +70,149 @@ describe('blurComposerInput', () => {
expect(document.activeElement).toBe(outside)
})
})
/**
* `markActiveComposer` has four call sites and, unguarded, no counterpart: an
* unmounting or keep-alive-buried composer left `activeTarget` pointing at
* itself, so every `'active'`-routed request was delivered to a target with no
* on-screen subscriber. Type-to-focus preventDefaults the keystroke BEFORE the
* request, so a dead target swallows the character and focuses nothing.
*/
describe('releaseActiveComposer', () => {
it('falls back to the main composer when the claimant releases', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
markActiveComposer('edit')
expect(getActiveComposer()).toBe('edit')
root.remove()
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('main')
})
it('leaves the key with the live claimant when a stale composer releases late', () => {
markActiveComposer('edit')
markActiveComposer('tile:abc')
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('tile:abc')
})
it('prefers the visible chat surface over a hard main default', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
mountSurface('tile:visible')
markActiveComposer('edit')
root.remove()
releaseActiveComposer('edit')
expect(getActiveComposer()).toBe('tile:visible')
})
it('routes an active-target request to the main composer once the edit composer closes', async () => {
// Mirrors the per-composer filter in use-composer-draft / user-edit-composer:
// a composer ignores any request not addressed to its own target.
const mainComposerSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainComposerSaw.push(target)
}
})
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
markActiveComposer('edit')
root.remove()
releaseActiveComposer('edit')
requestComposerFocus('active')
// `dispatch` defers to a macrotask so click/keydown handlers settle first.
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainComposerSaw).toEqual(['main'])
})
})
describe('resolveActive / keep-alive tab heal', () => {
it('heals type-to-focus onto the visible main tab when a tile is buried', async () => {
// Repro for the reported main-tab miss: user typed in a session tile, then
// clicked the main/workspace tab without focusing its input. The tile stays
// mounted under data-pane-hidden, so activeTarget still reads tile:… and
// every type-to-focus request is dropped by the visible main composer.
mountSurface('tile:buried', true)
mountSurface('main')
markActiveComposer('tile:buried')
expect(getActiveComposer()).toBe('main')
const mainSaw: string[] = []
const tileSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainSaw.push(target)
}
if (target === 'tile:buried') {
tileSaw.push(target)
}
})
requestComposerFocus('active', { typeChar: 'h' })
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainSaw).toEqual(['main'])
expect(tileSaw).toEqual([])
// Cache stays honest so dict/insert/Esc path all agree thereafter.
expect(getActiveComposer()).toBe('main')
})
it('keeps a live tile claim while that tile is the visible surface', () => {
mountSurface('main', true)
mountSurface('tile:front')
markActiveComposer('tile:front')
expect(getActiveComposer()).toBe('tile:front')
})
it('heals an edit claim once the edit root is gone (no release site needed)', async () => {
mountSurface('main')
markActiveComposer('edit')
// No edit root in the document → claim is dead. getActiveComposer heals.
expect(getActiveComposer()).toBe('main')
const mainSaw: string[] = []
const off = onComposerFocusRequest(({ target }) => {
if (target === 'main') {
mainSaw.push(target)
}
})
requestComposerFocus('active', { typeChar: 'a' })
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
expect(mainSaw).toEqual(['main'])
})
it('holds an edit claim while the edit composer root is mounted', () => {
const root = document.createElement('div')
root.dataset.slot = 'aui_edit-composer-root'
document.body.append(root)
mountSurface('main')
markActiveComposer('edit')
expect(getActiveComposer()).toBe('edit')
})
})

View file

@ -43,6 +43,21 @@ const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
/** Inline edit composer root — mounted only while a user bubble is being edited. */
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
/** Attribute-safe selector fragment. jsdom (vitest) does not ship `CSS.escape`. */
const cssEscape = (value: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value)
}
// Our targets are `'main'` / `'edit'` / `'tile:<id>'` — alphanumerics plus `:`
// and `-`. Escape anything outside that set so a weird id cannot break the
// attribute selector.
return value.replace(/[^a-zA-Z0-9_:-]/g, ch => `\\${ch}`)
}
interface SubmitDetail {
target: ComposerTarget
text: string
@ -50,7 +65,76 @@ interface SubmitDetail {
let activeTarget: ComposerTarget = 'main'
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? activeTarget : target)
/**
* The chat surface currently on screen (`data-composer-target` hung off each
* ChatView). Inactive tabs stay mounted with `data-pane-hidden`, so this uses
* the same visibility policy as every other document-wide surface lookup.
*/
const visibleChatTarget = (): ComposerTarget | null => {
if (typeof document === 'undefined') {
return null
}
const surface = queryVisible<HTMLElement>('[data-composer-target]')
const target = surface?.dataset.composerTarget
return target ? (target as ComposerTarget) : null
}
/** True when `target` still has a live, on-screen subscriber. */
const targetIsReachable = (target: ComposerTarget): boolean => {
if (typeof document === 'undefined') {
return true
}
// The edit composer is an in-thread overlay, not a chat surface — it never
// stamps `data-composer-target`. While its root is mounted it still owns the
// bus; once it tears down the claim is dead.
if (target === 'edit') {
return Boolean(document.querySelector(EDIT_COMPOSER_ROOT))
}
// Exact match on a VISIBLE surface. Background keep-alive tabs carry the same
// `data-composer-target` but sit under `data-pane-hidden`, so queryVisible
// filters them out.
if (queryVisible(`[data-composer-target="${cssEscape(target)}"]`)) {
return true
}
// A different chat surface is on screen → this claim is buried or gone.
// (A claim with zero stamped surfaces yet — first paint, pure-unit tests —
// keeps the marked key until the DOM contradicts it.)
if (queryVisible('[data-composer-target]')) {
return false
}
return true
}
/**
* The composer `'active'` should route to right now.
*
* The cached claim (`activeTarget`) wins while its surface is still on screen.
* Tab stacks keep inactive panes mounted, so focusing a tile then clicking the
* main tab leaves `activeTarget` pointing at a buried composer with no
* subscriber on the visible surface, every type-to-focus keystroke is
* preventDefault'd and dropped. Heal to the visible chat surface (or main)
* whenever the claim is off-screen or gone, and keep the cache honest so Esc /
* voice / soft `/` agree with the keyboard path.
*/
const resolveActive = (): ComposerTarget => {
if (targetIsReachable(activeTarget)) {
return activeTarget
}
const visible = visibleChatTarget() ?? 'main'
activeTarget = visible
return visible
}
const resolve = (target: ComposerTarget | 'active') => (target === 'active' ? resolveActive() : target)
const dispatch = <T>(name: string, detail: T) => {
if (typeof window === 'undefined') {
@ -82,9 +166,33 @@ export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
/** Hand the routing key back when a composer unmounts, so `'active'` can never
* resolve to a composer that no longer has a subscriber such a request is
* dispatched and then dropped by every mounted composer's target filter, and
* nothing re-marks the active composer on its own.
*
* Guarded on identity: a composer unmounting AFTER another one claimed the key
* (closing a background tile, a deferred edit-close cleanup) must not steal it
* from the live claimant. Falls through to {@link resolveActive} when the
* caller's surface is buried rather than gone, so closing on a tab switch that
* already re-fronted another chat surfaces there immediately. */
export const releaseActiveComposer = (target: ComposerTarget) => {
if (activeTarget !== target) {
return
}
// Prefer the visible chat surface over a hard `'main'` default — releasing a
// closed tile while another tile is fronted should land there, not the
// (possibly buried) workspace tab.
activeTarget = visibleChatTarget() ?? 'main'
}
/** The composer that last held focus the target `'active'` resolves to.
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
export const getActiveComposer = (): ComposerTarget => activeTarget
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one.
* Heals a stale claim the same way {@link requestComposerFocus} does, so Esc
* and type-to-focus never disagree after a tab switch left the bus pointing at
* a keep-alive-mounted background composer. */
export const getActiveComposer = (): ComposerTarget => resolveActive()
export const requestComposerFocus = (
target: ComposerTarget | 'active' = 'active',

View file

@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { type ComposerAttachment, mainComposerScope, stashSessionDraft } from '@/store/composer'
import type { QueueEditState } from '../composer-utils'
import { type ComposerTarget, getActiveComposer, markActiveComposer } from '../focus'
import { type ComposerScope, ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useComposerDraft } from './use-composer-draft'
@ -128,3 +130,48 @@ describe('useComposerDraft — rehydrate diagnostic log stays redacted', () => {
})
})
})
describe('useComposerDraft — a closing composer hands the focus-bus key back', () => {
afterEach(() => {
cleanup()
mainComposerScope.clear()
markActiveComposer('main')
})
function renderScoped(target: ComposerTarget) {
const scope: ComposerScope = { ...MAIN_COMPOSER_SCOPE, target }
return render(
<ComposerScopeProvider value={scope}>
<ProbeHarness
activeQueueSessionKey="session-tile"
onLayoutSnapshot={() => undefined}
sessionId="session-tile"
/>
</ComposerScopeProvider>
)
}
it('stops `active` resolving to a session tile once the tile unmounts', () => {
const { unmount } = renderScoped('tile:abc')
// Mounting claims the bus for this tile — the leak precondition.
expect(getActiveComposer()).toBe('tile:abc')
unmount()
expect(getActiveComposer()).toBe('main')
})
it('leaves the key alone when another composer claimed it before this one unmounted', () => {
const { unmount } = renderScoped('tile:abc')
expect(getActiveComposer()).toBe('tile:abc')
// The user clicks into a second tile, which claims the bus.
markActiveComposer('tile:other')
unmount()
expect(getActiveComposer()).toBe('tile:other')
})
})

View file

@ -17,7 +17,8 @@ import {
markActiveComposer,
onComposerFocusRequest,
onComposerInsertRefsRequest,
onComposerInsertRequest
onComposerInsertRequest,
releaseActiveComposer
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor'
@ -153,6 +154,15 @@ export function useComposerDraft({
}
}, [focusInput, focusKey, focusRequestId, inputDisabled])
// The mirror of the `markActiveComposer` above: give the key back when this
// composer goes away (a session tile closing, a pane unmounting). Covers both
// claim sites for this composer — `focusInput` here and ChatBar's `onFocus` —
// since they mark the same scope target. Without it `'active'` keeps
// resolving to a dead tile and every routed focus/insert request is dropped.
// (Heal-to-visible in focus.ts covers the keep-alive-tab case where the pane
// stays mounted behind the front tab; this covers true unmounts.)
useEffect(() => () => releaseActiveComposer(target), [target])
useEffect(() => {
if (inputDisabled) {
return undefined

View file

@ -2,6 +2,7 @@ import { useAuiState } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import {
chatSurfaceRoot,
clearSurfaceVar,
COMPOSER_HEIGHT_VAR,
COMPOSER_SURFACE_HEIGHT_VAR,
@ -168,11 +169,16 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
}, [poppedOut, syncComposerMetrics])
useEffect(() => {
const composer = composerRef.current
// Resolve the owning surface while the composer is still attached; the
// unmount cleanup runs after React detached the node, where closest()
// can no longer find [data-chat-surface] and would clear the document
// root instead of this surface (same class of bug as the status stack's
// stale-clearance leak).
const root = chatSurfaceRoot(composerRef.current)
return () => {
clearSurfaceVar(composer, COMPOSER_HEIGHT_VAR)
clearSurfaceVar(composer, COMPOSER_SURFACE_HEIGHT_VAR)
clearSurfaceVar(root, COMPOSER_HEIGHT_VAR)
clearSurfaceVar(root, COMPOSER_SURFACE_HEIGHT_VAR)
}
}, [composerRef])

View file

@ -1,11 +1,15 @@
import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { notifyError } from '@/store/notifications'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
import { $gateway } from '@/store/gateway'
import { notify, notifyError } from '@/store/notifications'
import { $autoSpeakReplies, $voiceStopPhrase, setAutoSpeakReplies } from '@/store/voice-prefs'
import { resumeWakeAfterVoice } from '@/store/wake-word'
import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
@ -54,6 +58,7 @@ export function useComposerVoice({
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
const voiceStartRequest = useStore($voiceConversationStartRequest)
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
@ -111,14 +116,30 @@ export function useComposerVoice({
await onSubmit(text)
}
const wakePausedRef = useRef(false)
// Resolves once the in-flight wake.pause round-trip completes (mic released by
// the wake listener). The conversation awaits this before opening its own mic
// so the two never contend for the device — on Windows especially, opening the
// capture device while the wake listener still holds it makes getUserMedia
// fail and the conversation never starts listening.
const wakePauseBarrierRef = useRef<Promise<void> | null>(null)
const conversation = useVoiceConversation({
busy,
consumePendingResponse,
enabled: voiceConversationActive,
onFatalError: () => setVoiceConversationActive(false),
// A spoken stop command ("stop", "never mind", "goodbye", …) ends the
// hands-free conversation. Flipping the flag is the authoritative off
// switch — the enabled=false prop + effect below drive conversation.end()
// teardown (mic close, wake re-arm).
onStopWord: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse: pendingTurnResponse
pendingResponse: pendingTurnResponse,
// Before the conversation opens the mic, wait for any in-flight wake.pause
// to finish releasing the capture device (see wakePauseBarrierRef).
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
@ -142,6 +163,73 @@ export function useComposerVoice({
[target, toggleVoiceConversation]
)
useEffect(() => {
if (target === 'main' && !disabled && takeVoiceConversationStart(voiceStartRequest) && !voiceConversationActive) {
setVoiceConversationActive(true)
}
}, [disabled, target, voiceConversationActive, voiceStartRequest])
const resumeWakeIfPaused = useCallback(() => {
if (!wakePausedRef.current) {
return
}
wakePausedRef.current = false
wakePauseBarrierRef.current = null
// Reconcile, don't just resume: the wake word is a persistent setting, so
// ending a voice chat must re-arm the listener whenever config says
// enabled — including when the raw resume loses the mic-release race.
void resumeWakeAfterVoice()
}, [])
// The ref is a request token (did WE issue wake.pause?), not an atom mirror —
// it guards resumeWakeIfPaused from resuming a detector another surface owns.
const pauseWakeForVoice = useCallback(() => {
wakePausedRef.current = true
const barrier = (async () => {
try {
await $gateway.get()?.request('wake.pause', {})
} catch {
// No wake listener / older backend — nothing held the mic.
}
})()
wakePauseBarrierRef.current = barrier
return barrier
}, [])
useEffect(() => {
if (voiceConversationActive) {
pauseWakeForVoice()
} else {
resumeWakeIfPaused()
}
}, [pauseWakeForVoice, resumeWakeIfPaused, voiceConversationActive])
// 'Say "stop" to end the voice chat.' notice when the conversation starts.
// Phrase comes from voice.stop_phrases (first entry) so a custom phrase
// renders correctly; a null phrase (stop_phrases: []) shows no notice.
useEffect(() => {
if (!voiceConversationActive) {
return
}
const phrase = $voiceStopPhrase.get()
if (phrase) {
notify({
id: 'voice-stop-hint',
kind: 'info',
icon: 'mic',
message: t.notifications.voice.sayStopToEnd(phrase)
})
}
}, [t, voiceConversationActive])
useEffect(() => resumeWakeIfPaused, [resumeWakeIfPaused])
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
const startConversation = useCallback(() => setVoiceConversationActive(true), [])

View file

@ -0,0 +1,47 @@
import { useEffect } from 'react'
import { useSessionSlice } from '@/lib/use-session-slice'
import { setComposerActions } from '@/store/composer-actions'
import { $todosBySession } from '@/store/todos'
import { type ComposerMicroActionContext, useComposerMicroActionProviders } from '../contrib'
/**
* Resolve every registered micro-action provider for this session and publish
* the result to `$composerActionsBySession`, which the pill strip renders.
*
* Core registers nothing, so the strip stays empty until something contributes
* to `composer.microActions`. Providers are pure functions of the session
* context and the set is recomputed rather than mutated, so there are no
* ordering games between registrars and a provider that stops returning a
* badge withdraws it. One that throws is skipped, so a broken plugin loses
* only its own badge.
*/
export function useComposerMicroActions(sessionId: null | string, busy: boolean) {
const todos = useSessionSlice($todosBySession, sessionId)
const providers = useComposerMicroActionProviders()
useEffect(() => {
if (!sessionId) {
return
}
const ctx: ComposerMicroActionContext = { busy, sessionId, todos }
setComposerActions(
sessionId,
providers.flatMap(provider => {
try {
return provider.resolve(ctx) ?? []
} catch {
return []
}
})
)
}, [busy, providers, sessionId, todos])
// Withdraw on unmount / session switch ONLY. Clearing in the resolve effect's
// cleanup would publish an empty set before every republish — two store
// writes and two stack re-renders for what is usually a no-op.
useEffect(() => (sessionId ? () => setComposerActions(sessionId, []) : undefined), [sessionId])
}

View file

@ -49,7 +49,15 @@ function gestureTargetOk(target: EventTarget | null) {
return false
}
return !target.closest('button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper]')
// `composer-no-drag`: chrome that lives inside the composer root but isn't
// part of the draggable frame — the floating pill strips. The pills are
// `button`s and already excluded, but the strip's own box (the gaps between
// pills) isn't, so without this a press landing between two badges still
// drags. The strips are `w-fit`, so this costs the grab band only the width
// of the badges themselves.
return !target.closest(
'button, a, input, textarea, select, [role="menuitem"], [data-radix-popper-content-wrapper], [data-slot="composer-no-drag"]'
)
}
/** Floating composer's 5px outer frame — grab here to drag without long-press. */

View file

@ -18,6 +18,29 @@ const CATALOG = {
]
}
// A catalog shaped like a real install: a couple of skills the user lives in,
// a bundled one they have never opened, and one of their own they haven't
// either.
const RANKED_CATALOG = {
categories: [{ name: 'Session', pairs: [['/new', 'Start a new session']] }],
pairs: [
['/new', 'Start a new session'],
['/docx', 'Edit Word documents'],
['/research', 'Look it up before answering'],
['/research-paper-writing', 'Write an academic paper'],
['/work', 'Kick off a task in a fresh worktree']
],
skills: {
'/docx': { usage: 0, origin: 'local' },
'/research': { usage: 60, origin: 'local' },
'/research-paper-writing': { usage: 0, origin: 'bundled' },
'/work': { usage: 172, origin: 'local' }
}
}
const commandsOf = (items: readonly Unstable_TriggerItem[]) =>
items.map(item => (item.metadata as { command?: string })?.command)
function harness(gateway: HermesGateway) {
const api: { search?: (query: string) => readonly Unstable_TriggerItem[] } = {}
@ -95,4 +118,40 @@ describe('useSlashCompletions', () => {
expect(inline.map(item => (item.metadata as { command?: string })?.command)).toEqual(['/work'])
})
// An alphabetical `/` menu buries the skills someone runs daily under the
// ones that shipped with Hermes and were never opened.
it('orders skills by use and hides never-used built-ins on a bare slash', async () => {
const request = vi.fn().mockResolvedValue(RANKED_CATALOG)
const api = harness({ request } as unknown as HermesGateway)
const skills = commandsOf((await completions(api, '')).filter(isSkillItem))
expect(skills).toEqual(['/work', '/research', '/docx'])
})
// Typing is a search, and a search that hides a match is broken — the
// never-used built-in still shows, just below the one she actually uses.
it('ranks a typed query by use without hiding anything', async () => {
const request = vi.fn().mockImplementation((method: string) =>
Promise.resolve(
method === 'commands.catalog'
? RANKED_CATALOG
: {
items: [
{ text: '/research-paper-writing', display: '/research-paper-writing', meta: 'Write a paper' },
{ text: '/research', display: '/research', meta: 'Look it up' }
]
}
)
)
const api = harness({ request } as unknown as HermesGateway)
// Warm the catalog first: the popover always opens on a bare `/` before a
// query is typed, which is where the usage map comes from.
await completions(api, '')
expect(commandsOf(await completions(api, 'research'))).toEqual(['/research', '/research-paper-writing'])
})
})

View file

@ -11,9 +11,15 @@ import {
type DesktopThemeCommandOption,
filterDesktopCommandsCatalog,
isDesktopSlashExtensionCommand,
isDesktopSlashSuggestion
isDesktopSlashSuggestion,
rankSkillCommands
} from '@/lib/desktop-slash-commands'
import { $slashCompletionsEpoch, cachedSlashCompletion, hasCachedSlashCompletion } from '@/lib/slash-completion-cache'
import {
$slashCompletionsEpoch,
cachedSlashCompletion,
hasCachedSlashCompletion,
peekCachedSlashCompletion
} from '@/lib/slash-completion-cache'
import { normalize } from '@/lib/text'
import { $sessions } from '@/store/session'
@ -145,7 +151,7 @@ export function useSlashCompletions(options: {
// backend didn't categorize.
const sections = catalog.categories?.length ? catalog.categories : [{ name: '', pairs: catalog.pairs ?? [] }]
const items = sections.flatMap(section =>
const items = sections.flatMap<CompletionEntry>(section =>
section.pairs.map(([command, meta]) => ({
text: command,
display: command,
@ -161,13 +167,19 @@ export function useSlashCompletions(options: {
// Re-add the leftovers under one Skills header (which also gives them
// the skill pill accent and makes them offerable mid-message).
const categorized = new Set(items.map(item => item.text.toLowerCase()))
const skillRows: CompletionEntry[] = []
for (const [command, meta] of catalog.pairs ?? []) {
if (!categorized.has(command.toLowerCase()) && isDesktopSlashExtensionCommand(command)) {
items.push({ text: command, display: command, group: 'Skills', meta })
skillRows.push({ text: command, display: command, group: 'Skills', meta })
}
}
// Browsing, not searching: rank the skills the user actually reaches
// for to the top and drop never-used built-ins entirely. Typing a
// query takes the other branch, where nothing is hidden.
items.push(...rankSkillCommands(skillRows, catalog.skills, { pruneUnusedBuiltins: true }))
return { items, query }
}
@ -210,9 +222,27 @@ export function useSlashCompletions(options: {
// Skills (stable within a group, preserving backend relevance order).
const groupOrder = ['Commands', 'Skills', 'Options']
const items = isArgCompletion
? decorated
: [...decorated].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
if (isArgCompletion) {
return { items: decorated, query }
}
// Rank the matched skills by use — `/re` should lead with the /research
// the user lives in, not the /research-paper-writing they've never
// opened. Nothing is pruned here: a typed query is a search, and a
// search that hides a match is broken. Usage rides along on the catalog
// response, which the popover has already fetched by the time anyone
// types; if it somehow hasn't, order falls back to the backend's.
const catalogSkills = peekCachedSlashCompletion<CommandsCatalogLike>('catalog')?.skills
const ranked = [
...decorated.filter(item => item.group !== 'Skills'),
...rankSkillCommands(
decorated.filter(item => item.group === 'Skills'),
catalogSkills
)
]
const items = [...ranked].sort((a, b) => groupOrder.indexOf(a.group) - groupOrder.indexOf(b.group))
return { items, query }
} catch {

View file

@ -1,26 +1,37 @@
import { useSyncExternalStore } from 'react'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
/** Structural view of the three per-session feeds they hold different item
* types, and all this hook needs from each is "does this key have rows". */
interface PresenceFeed {
get(): Record<string, undefined | unknown[]>
listen(listener: () => void): () => void
}
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
const subscribe = (onChange: () => void) => {
const offItems = $statusItemsBySession.listen(onChange)
const offPreviews = $previewStatusBySession.listen(onChange)
const offs = FEEDS.map(feed => feed.listen(onChange))
return () => {
offItems()
offPreviews()
for (const off of offs) {
off()
}
}
}
/**
* Whether a session has any status items or previews, as a coarse *edge*: the
* boolean only flips when the stack appears/disappears. ChatBar uses it to
* toggle a styling data-attr subscribing to the whole `$statusItemsBySession`
* (a `computed` that rebuilds the entire map) / `$previewStatusBySession` maps
* re-rendered the ~1.4k ChatBar on every per-item mutation (a subagent tick, a
* 5s background poll) and on churn in OTHER sessions. The boolean snapshot bails
* out of all of that, re-rendering only on the actual show/hide transition.
* Whether a session has any status items, micro actions, or previews, as a
* coarse *edge*: the boolean only flips when the stack appears/disappears.
* ChatBar uses it to toggle a styling data-attr subscribing to the whole
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
* per-item mutation (a subagent tick, a 5s background poll) and on churn in
* OTHER sessions. The boolean snapshot bails out of all of that, re-rendering
* only on the actual show/hide transition.
*/
export function useSessionStatusPresence(sessionId: string | null): boolean {
return useSyncExternalStore(subscribe, () => {
@ -28,9 +39,6 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
return false
}
return (
($statusItemsBySession.get()[sessionId]?.length ?? 0) > 0 ||
($previewStatusBySession.get()[sessionId]?.length ?? 0) > 0
)
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
})
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { startThinkingSound, stopThinkingSound } from '@/lib/thinking-sound'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
markVoicePlaybackInterrupted,
@ -9,7 +10,9 @@ import {
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { isVoiceStopCommand } from '@/lib/voice-stop-word'
import { notify, notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
import { useMicRecorder } from './use-mic-recorder'
@ -25,20 +28,26 @@ interface VoiceConversationOptions {
busy: boolean
enabled: boolean
onFatalError?: () => void
onStopWord?: () => void
onSubmit: (text: string) => Promise<void> | void
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
consumePendingResponse: () => void
/** Awaited right before the mic is opened. Used to let the wake-word listener
* fully release the capture device first, so the two never contend. */
beforeMicOpen?: () => Promise<void> | void
}
export function useVoiceConversation({
busy,
enabled,
onFatalError,
onStopWord,
onSubmit,
onTranscribeAudio,
pendingResponse,
consumePendingResponse
consumePendingResponse,
beforeMicOpen
}: VoiceConversationOptions) {
const { t } = useI18n()
const voiceCopy = t.notifications.voice
@ -54,11 +63,25 @@ export function useVoiceConversation({
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const speechStartSequenceRef = useRef(0)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
const statusRef = useRef<ConversationStatus>('idle')
const wasEnabledRef = useRef(enabled)
const onStopWordRef = useRef(onStopWord)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
onStopWordRef.current = onStopWord
}, [onStopWord])
const beforeMicOpenRef = useRef(beforeMicOpen)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
beforeMicOpenRef.current = beforeMicOpen
}, [beforeMicOpen])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
@ -132,6 +155,18 @@ export function useVoiceConversation({
return
}
// A spoken "stop" (or "never mind", "goodbye", …) ends the
// conversation instead of being submitted as a turn. Only whole-
// utterance stop commands match, so "stop the container" still goes
// through as a real request.
if (isVoiceStopCommand(transcript)) {
dropSpeechSession()
setStatus('idle')
onStopWordRef.current?.()
return
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
await onSubmit(transcript)
@ -167,6 +202,20 @@ export function useVoiceConversation({
return
}
// Let the wake-word listener fully release the capture device before we
// open ours — opening the mic while wake still holds it makes getUserMedia
// fail (the "clicked voice but it never starts listening" bug).
try {
await beforeMicOpenRef.current?.()
} catch {
// A pause failure shouldn't block the user's explicit start.
}
// enabled/muted/busy or an interleaved turn may have changed while we waited.
if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') {
return
}
try {
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({
@ -181,6 +230,12 @@ export function useVoiceConversation({
onSilence: () => void handleTurn()
})
setStatus('listening')
// Clear any prior turn-timeout before arming a fresh one. Each listen
// cycle reassigns turnTimeoutRef; without clearing first, a stale 60s
// timer from an earlier cycle survives and later fires handleTurn() in
// the middle of a new listen, cutting it short (or, after enough idle
// re-listens, wedging the loop into a state it doesn't re-arm from).
clearTurnTimeout()
turnTimeoutRef.current = window.setTimeout(() => void handleTurn(), 60_000)
} catch (error) {
notifyError(error, voiceCopy.couldNotStartSession)
@ -211,7 +266,15 @@ export function useVoiceConversation({
dropSpeechSession()
if (enabledRef.current) {
// If stopVoicePlayback() was called externally (Stop button, end), the
// voice-playback sequence has advanced past what we captured at speech
// start — don't auto-start the next sentence, the user chose to stop.
const stoppedByUser =
speechStartSequenceRef.current > 0 && $voicePlayback.get().sequence > speechStartSequenceRef.current
speechStartSequenceRef.current = 0
if (enabledRef.current && !stoppedByUser) {
pendingStartRef.current = true
}
@ -341,6 +404,8 @@ export function useVoiceConversation({
barged = true
})
speechStartSequenceRef.current = $voicePlayback.get().sequence
void playSpeechText(response.text, { source: 'voice-conversation' })
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
@ -365,6 +430,7 @@ export function useVoiceConversation({
(responseId: string) => {
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
speechStartSequenceRef.current = $voicePlayback.get().sequence
setStatus('speaking')
let barged = false
@ -509,6 +575,22 @@ export function useVoiceConversation({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Ambient "thinking" sound: while the agent works (status 'thinking') no
// audio flows, which reads as dead air mid-conversation. Calm bubble blips
// fill the gap; they stop the INSTANT speech starts, the mic re-arms, or the
// conversation ends. Gated by voice.thinking_sound + the shared sound mute.
useEffect(() => {
if (enabled && !muted && status === 'thinking') {
startThinkingSound()
return stopThinkingSound
}
stopThinkingSound()
return undefined
}, [enabled, muted, status])
// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.

View file

@ -2,7 +2,7 @@ import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { composerFill, composerFloatingStrip, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { interceptsTypedVoiceStop } from '@/lib/voice-stop-word'
import { sessionCompacting } from '@/store/compaction'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
@ -48,6 +49,7 @@ import { useComposerTrigger } from './hooks/use-composer-trigger'
import { useComposerUndo } from './hooks/use-composer-undo'
import { useComposerUrlDialog } from './hooks/use-composer-url-dialog'
import { useComposerVoice } from './hooks/use-composer-voice'
import { useComposerMicroActions } from './hooks/use-micro-actions'
import { useSlashCompletions } from './hooks/use-slash-completions'
import { useSessionStatusPresence } from './hooks/use-status-presence'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
@ -94,11 +96,32 @@ export function ChatBar({
onSubmit: onSubmitProp,
onTranscribeAudio
}: ChatBarProps) {
// Typed stop phrase during an active voice conversation ends it — same
// semantics as SAYING "stop" (voice-stop-word.ts) or clicking the pill's
// end control. Populated after useComposerVoice below (the submit wrapper
// is created first); render-time assignment keeps the ref current.
const voiceStopRef = useRef<{ active: boolean; end: () => void }>({ active: false, end: () => {} })
// Every send (typed, queued, voice) passes through the contributed
// middleware chain first — rewrite / pass-through / cancel. Empty chain =
// exact pass-through, so surfaces without contributions are byte-identical.
const onSubmit = useCallback<ChatBarProps['onSubmit']>(
async (value, options) => {
// Bare stop phrase typed while the voice conversation is live: end the
// conversation (mic off, pill dismissed) instead of sending "stop" to
// the agent. Spoken transcripts are already stop-checked inside
// use-voice-conversation, so this only catches typed/queued sends.
// Outside a voice conversation, typed "stop" is a normal message.
const voiceStop = voiceStopRef.current
if (interceptsTypedVoiceStop(voiceStop.active, value, options?.attachments?.length ?? 0)) {
voiceStop.end()
// Consumed (not rejected): report accepted so the submit engine
// clears the draft instead of restoring "stop" into the composer.
return true
}
const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
if (!draft) {
@ -133,6 +156,10 @@ export function ChatBar({
// every per-item status mutation or other sessions' churn (see the hook).
const statusPresent = useSessionStatusPresence(statusSessionId)
// Publishes contributed micro actions for this session; the status stack
// renders them as the pill strip at the top of the overlay lane.
useComposerMicroActions(statusSessionId, busy)
const composerRef = useRef<HTMLFormElement | null>(null)
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
@ -835,6 +862,11 @@ export function ChatBar({
target: scope.target
})
// Keep the typed-stop interceptor (see onSubmit above) in sync with the
// live conversation state. Render-time ref assignment, same pattern as
// dispatchSubmitRef — no effect needed for a plain mirror.
voiceStopRef.current = { active: voiceConversationActive, end: endConversation }
const contextMenu = (
<ContextMenu
onInsertText={insertText}
@ -1076,7 +1108,14 @@ export function ChatBar({
className={cn('pointer-events-auto absolute inset-0', dragging ? 'cursor-grabbing' : 'cursor-grab')}
data-dragging={dragging ? '' : undefined}
data-slot="composer-drag-region"
onDoubleClick={handleComposerToggle}
onDoubleClick={event => {
// The pill strips paint above this region; a double-click that
// lands on one must not float the composer. onPointerDown goes
// through gestureTargetOk, but this handler doesn't.
if (!(event.target as Element).closest('[data-slot="composer-no-drag"]')) {
handleComposerToggle()
}
}}
/>
)}
<div className="relative w-full rounded-[inherit]">
@ -1168,6 +1207,17 @@ export function ChatBar({
</div>
</div>
</div>
{/* Underside: a floating strip BELOW the whole composer surface.
Chrome-free by design contributions bring their own pill/skin,
like the micro-action strip above. In flow (the root is
bottom-anchored, so this grows the composer upward and stays on
screen) but OUTSIDE the surface, so it escapes the surface's
clipping, border, and scroll fade. Shares the micro-action
strip's grid so the two bracket the composer on one vertical
line. Renders nothing until something contributes. */}
<div className={cn(composerFloatingStrip, 'pt-1.5 empty:hidden')} data-slot="composer-no-drag">
<ContribSlot area={COMPOSER_AREAS.underside} />
</div>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

View file

@ -0,0 +1,78 @@
import { memo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import type { ComposerAction } from '@/store/composer-actions'
import { notifyError } from '@/store/notifications'
/**
* Floating pill the treatment the thread's jump/approval button uses for a
* control that sits over scrolling content: full radius, hairline border, the
* shared composer fill behind a blur so thread text never bleeds through.
* Sized against the composer's own control height so a row of pills lines up
* with the chrome it floats above.
*
* NEVER `pointer-events-none`, not even when disabled. The pop-out drag region
* is an `absolute` sibling behind these pills, so a pill that stops taking
* pointer events hands the hit test straight to it and a dead-looking badge
* becomes a grab handle that floats the composer.
*/
const PILL = cn(
'inline-flex h-(--composer-control-size) max-w-56 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5',
'border border-border/65 bg-(--composer-fill) backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
'text-xs font-normal text-(--ui-text-secondary) transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground',
'disabled:cursor-default disabled:opacity-50 disabled:hover:bg-(--composer-fill)',
'focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50'
)
/**
* The micro-action pills. Layout-free on purpose the composer owns the strip
* (`composerFloatingStrip`), this owns only the pills, so the strip above the
* surface and the `composer.underside` strip below it can't drift apart.
*/
export const ActionBadges = memo(function ActionBadges({
actions,
sessionId
}: {
actions: ComposerAction[]
sessionId: string
}) {
// A pill can kick off async work (a gateway call, a submit). Track which one
// is in flight so it can spin and lock instead of double-firing.
const [runningId, setRunningId] = useState<null | string>(null)
const run = async (action: ComposerAction) => {
if (runningId) {
return
}
setRunningId(action.id)
try {
await action.run(sessionId)
} catch (error) {
notifyError(error, action.label)
} finally {
setRunningId(null)
}
}
return actions.map(action => {
const running = runningId === action.id
const glyph = running ? 'loading' : action.icon
return (
<button
className={PILL}
disabled={action.disabled || Boolean(runningId)}
key={action.id}
onClick={() => void run(action)}
type="button"
>
{glyph && <Codicon className="shrink-0 opacity-70" name={glyph} size="0.75rem" spinning={running} />}
<span className="truncate">{action.label}</span>
</button>
)
})
})

View file

@ -3,10 +3,10 @@ import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'rea
import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { chatSurfaceRoot, clearSurfaceVar, setSurfaceVar, STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { AGENTS_ROUTE } from '@/app/routes'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { composerDockCard, composerFloatingStrip } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@ -15,6 +15,7 @@ import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import { $composerActionsBySession } from '@/store/composer-actions'
import {
$statusItemsBySession,
type ComposerStatusItem,
@ -29,6 +30,7 @@ import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { ActionBadges } from './action-badges'
import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
@ -93,6 +95,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const actions = useSessionSlice($composerActionsBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
@ -151,6 +154,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
// Micro actions ride at the top of the stack — the one block you press
// rather than read. Rendered OUTSIDE the card (see `actionStrip`) so the
// pills float; a blocked account still gets the billing wall above them.
for (const group of groups) {
sections.push({
key: group.type,
@ -208,7 +215,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'queue', node: queue })
}
const visible = sections.length > 0
// Micro actions are the TOP-MOST thing in the whole overlay lane — above the
// status card, above the billing wall, above everything. They're the only
// rows up here you press instead of read, so nothing may ever stack on top
// of them. Rendered outside the card (below) so the pills float.
const actionStrip = actions.length > 0 && sessionId ? <ActionBadges actions={actions} sessionId={sessionId} /> : null
const visible = sections.length > 0 || Boolean(actionStrip)
const stackRef = useRef<HTMLDivElement | null>(null)
// The stack is out of flow (overlays the thread), so the composer's measured
@ -220,11 +233,16 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const el = stackRef.current
if (!visible || !el) {
clearSurfaceVar(el, STATUS_STACK_VAR)
return
}
// Resolve the owning surface NOW, while the node is attached. The cleanup
// below runs after the stack collapsed and React removed the div, so
// closest() from the detached node misses [data-chat-surface] and would
// clear the document root instead — leaving the stale height on the
// surface, which keeps inflating the thread's bottom clearance until the
// next publish.
const root = chatSurfaceRoot(el)
let last = -1
const sync = () => {
@ -242,7 +260,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
return () => {
observer.disconnect()
clearSurfaceVar(el, STATUS_STACK_VAR)
clearSurfaceVar(root, STATUS_STACK_VAR)
}
}, [visible])
@ -255,29 +273,52 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// Sits in the overlay lane above the composer. The composer root has pt-2
// before the actual surface; translate by that amount so the stack returns
// to its original attachment point without intruding into the repo strip.
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
// pl matches the surface's own left edge: `inset-x-0` resolves against the
// root's PADDING box, while the surface and the underside strip sit in its
// CONTENT box, so without it the lane hangs 5px further left than both.
className="absolute inset-x-0 bottom-full z-3 flex max-h-[40vh] flex-col translate-y-2 pl-[0.3125rem]"
onPointerDownCapture={() => blurComposerInput()}
ref={stackRef}
>
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
{/* FIRST in the lane and OUTSIDE the scroller, so nothing can ever sit
above the pills not the status card, not the billing wall and a
long todo list can't scroll them out of view. Outside the card too:
they carry their own fill, so they must not paint on its background. */}
{actionStrip && (
<div
className={cn(
composerFloatingStrip,
'shrink-0 pb-1.5 transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{actionStrip}
</div>
)}
{/* Everything else scrolls under them. */}
<div className="min-h-0 overflow-y-auto">
{/* The card paints the shared --composer-fill (rest / scrolled / focused
all match the composer surface by construction); on scroll we only
ghost the CONTENT element opacity on the card would kill the blur.
Rounded top, square bottom; the bottom border is TRANSPARENT the
composer surface's visible top border (which sits at a higher z) is the
single shared seam, so the two read as one fused capsule. */}
{sections.length > 0 && (
<div
className={cn(
composerDockCard('top'),
// Inset (mx-2) so the stack reads slightly narrower than the composer
// surface below it — the original look.
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
'transition-opacity duration-200 ease-out',
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
)}
>
{sections.map(section => (
<div key={section.key}>{section.node}</div>
))}
</div>
</div>
)

View file

@ -0,0 +1,94 @@
import { act, cleanup, render } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { STATUS_STACK_VAR } from '@/app/chat/surface-vars'
import { I18nProvider } from '@/i18n'
import { $goalsBySession, type SessionGoal } from '@/store/goals'
import { ComposerStatusStack } from './index'
// The stack measures itself into a surface var — jsdom has no ResizeObserver.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-height-1'
const goal = (): SessionGoal => ({ status: 'active', title: 'ship the feature', updatedAt: Date.now() })
/**
* Regression: when the stack collapses (its last item finishes), React removes
* the stack div BEFORE the layout-effect cleanup runs. Resolving the surface
* from the ref at cleanup time then walks a DETACHED node, misses
* [data-chat-surface], and clears the document root instead the stale height
* stays on the surface and keeps inflating the thread's bottom clearance
* (`--thread-last-message-clearance`) until the next publish. The effect must
* capture its surface root while the node is still attached.
*/
describe('ComposerStatusStack surface-var lifecycle', () => {
beforeEach(() => {
$goalsBySession.set({})
})
afterEach(() => {
cleanup()
$goalsBySession.set({})
document.documentElement.style.removeProperty(STATUS_STACK_VAR)
})
function renderOnSurface() {
const surface = document.createElement('div')
surface.setAttribute('data-chat-surface', '')
document.body.append(surface)
const view = render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={SID} />
</I18nProvider>
</MemoryRouter>,
{ container: surface }
)
return { surface, view }
}
it('publishes its measured height onto the owning surface while visible', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
// jsdom measures 0 — the value is irrelevant, the target element is not.
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
expect(document.documentElement.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var when the stack collapses to nothing', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
// Last status item goes away → the component renders null and React
// detaches the stack div before the cleanup runs.
act(() => $goalsBySession.set({}))
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
it('clears the surface var on unmount', () => {
$goalsBySession.set({ [SID]: goal() })
const { surface, view } = renderOnSurface()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('0px')
view.unmount()
expect(surface.style.getPropertyValue(STATUS_STACK_VAR)).toBe('')
})
})

View file

@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { type Translations, useI18n } from '@/i18n'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
@ -111,11 +112,15 @@ export function SessionStatusDot({ storedSessionId, session, branchStem, classNa
useStore($sessionColorById)
const color = sessionColorFor(session) ?? null
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
// Per-session membership as booleans via useStoreSelector: these arrays tick
// on every stream delta (any session working/stalled/etc changes the array
// reference), but a given dot only repaints when ITS OWN membership flips.
// A plain useStore(array).includes(id) re-rendered every dot on every tick.
const needsInput = useStoreSelector($attentionSessionIds, ids => ids.includes(storedSessionId))
const isWorking = useStoreSelector($workingSessionIds, ids => ids.includes(storedSessionId))
const isStalled = useStoreSelector($stalledSessionIds, ids => ids.includes(storedSessionId))
const isUnread = useStoreSelector($unreadFinishedSessionIds, ids => ids.includes(storedSessionId))
const hasBackground = useStoreSelector($backgroundRunningSessionIds, ids => ids.includes(storedSessionId))
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })

View file

@ -169,7 +169,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const next = await uploadComposerAttachment(attachment, {
backendCwd: readState()?.cwd,
remote,
requestGateway,
sessionId
})
if (options.updateComposerAttachments ?? true) {
scope.attachments.update(next)

View file

@ -12,6 +12,7 @@ import { useI18n } from '@/i18n'
import { fmtDayTime, relativeTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { updateCronJobs } from '@/store/cron'
import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { $selectedStoredSessionId } from '@/store/session'
import type { CronJob } from '@/types/hermes'
@ -27,9 +28,11 @@ const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// without turning the sidebar into the full Cron page.
const PEEK_RUN_LIMIT = 5
// Runs are written by the background scheduler tick (no UI signal), so poll the
// open peek so a freshly-fired run shows up within a few seconds.
// Runs are written by the background scheduler tick. cron.changed reloads the
// open peek immediately on event-capable backends (poll drops to a backstop);
// older backends keep the legacy cadence.
const PEEK_POLL_INTERVAL_MS = 8000
const PEEK_BACKSTOP_INTERVAL_MS = 60_000
// Keep the section compact: show a few jobs up front, reveal more in larger
// steps on demand (mirrors the messaging sections in the sidebar).
@ -322,6 +325,8 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
const changeEventsAvailable = useStore($changeEventsAvailable)
const cronChangeTick = useStore($cronChangeTick)
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
useEffect(() => {
@ -342,17 +347,21 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
void load()
}
}, PEEK_POLL_INTERVAL_MS)
const intervalId = window.setInterval(
() => {
if (document.visibilityState === 'visible') {
void load()
}
},
changeEventsAvailable ? PEEK_BACKSTOP_INTERVAL_MS : PEEK_POLL_INTERVAL_MS
)
return () => {
cancelled = true
window.clearInterval(intervalId)
}
}, [jobId])
// cronChangeTick: a fired run reloads the peek immediately.
}, [changeEventsAvailable, cronChangeTick, jobId])
return (
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">

View file

@ -384,11 +384,10 @@ export function ChatSidebar({
[sessions, showAllProfiles, profileScope]
)
// Agent session order is pinned to creation time (started_at), NOT activity —
// a new message must never float a session to the top. Position only changes
// for a brand-new session or an explicit manual drag (agentOrderIds).
// Recents by activity (last_active || started_at). User send stamps
// last_active immediately; manual drag order still wins below.
const sortedSessions = useMemo(
() => [...visibleSessions].sort((a, b) => (b.started_at || 0) - (a.started_at || 0)),
() => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
[visibleSessions]
)

View file

@ -1,4 +1,5 @@
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
@ -54,7 +55,7 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
return unit === 'second' ? r.ageNow : `${value}${r[AGE_KEY[unit]]}`
}
export function SidebarSessionRow({
function SidebarSessionRowImpl({
session,
branchStem,
isPinned,
@ -251,3 +252,32 @@ export function SidebarSessionRow({
</SessionContextMenu>
)
}
// The sidebar re-renders on every stream tick ($sessions/$workingSessionIds
// churn), and it stays mounted beneath every overlay — so an unmemoized row
// re-rendered the whole list (and its Codicon/label/status-dot subtree) on each
// delta, bleeding churn into Settings, Cron, Profiles, Artifacts, etc.
//
// The callback props (onArchive/onResume/…) are fresh closures every render by
// design (they close over the row's session id), so a default memo never bails.
// They're pure id-forwarders, though — identical behavior for a given row — so
// the comparator deliberately ignores them and compares only the DATA that
// changes what the row paints. A row whose session/selection/working/pin state
// is unchanged now bails out, even while a sibling session streams.
function rowPropsEqual(a: SidebarSessionRowProps, b: SidebarSessionRowProps): boolean {
return (
a.session === b.session &&
a.isPinned === b.isPinned &&
a.isSelected === b.isSelected &&
a.isWorking === b.isWorking &&
a.branchStem === b.branchStem &&
a.reorderable === b.reorderable &&
a.dragging === b.dragging &&
a.showProfile === b.showProfile &&
a.dragHandleProps === b.dragHandleProps &&
a.className === b.className &&
a.style === b.style
)
}
export const SidebarSessionRow = memo(SidebarSessionRowImpl, rowPropsEqual)

View file

@ -1,4 +1,3 @@
import { useStore } from '@nanostores/react'
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LogTail } from '@/components/chat/log-tail'
@ -26,6 +25,7 @@ import {
} from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { fmtDateTime } from '@/lib/time'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
@ -48,6 +48,12 @@ const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
// Stable empty arrays so the selector returns the same reference when we're
// not on the Sessions tab — useStoreSelector bails out on Object.is, so the
// component never re-renders from $sessions ticks while on System/Usage/etc.
const EMPTY_SESSIONS: readonly never[] = []
const EMPTY_PINNED: readonly string[] = []
interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
@ -129,10 +135,12 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
const { t } = useI18n()
const cc = t.commandCenter
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
// $sessions ticks on every streaming token (title updates, new sessions),
// but we only need the data on the Sessions tab. Subscribe conditionally so
// the System/Usage/Maintenance tabs don't re-render on every stream delta.
const [section, setSection] = useRouteEnumParam('section', SECTIONS, initialSection ?? 'sessions')
const sessions = useStoreSelector($sessions, s => (section === 'sessions' ? s : EMPTY_SESSIONS))
const pinnedSessionIds = useStoreSelector($pinnedSessionIds, s => (section === 'sessions' ? s : EMPTY_PINNED))
const [query, setQuery] = useState('')
const [status, setStatus] = useState<StatusResponse | null>(null)
@ -294,25 +302,29 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
[cc, refreshSystem]
)
const navGroups = useMemo(
() =>
SECTIONS.map(value => ({
active: section === value,
icon:
value === 'sessions'
? MessageCircle
: value === 'system'
? Activity
: value === 'maintenance'
? Wrench
: BarChart3,
id: value,
label: cc.sections[value],
onSelect: () => setSection(value)
})),
[cc, section, setSection]
)
return (
<OverlayView closeLabel={cc.close} onClose={onClose}>
<OverlaySplitLayout>
<OverlayNav
groups={SECTIONS.map(value => ({
active: section === value,
icon:
value === 'sessions'
? MessageCircle
: value === 'system'
? Activity
: value === 'maintenance'
? Wrench
: BarChart3,
id: value,
label: cc.sections[value],
onSelect: () => setSection(value)
}))}
/>
<OverlayNav groups={navGroups} />
<OverlayMain>
<header className="mb-4 flex items-center justify-between gap-3 max-[47.5rem]:mb-2">

View file

@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
@ -49,6 +49,7 @@ import {
} from '@/lib/icons'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { resolveVersionStatus } from '@/lib/version-status'
import { $repoWorktrees } from '@/store/coding-status'
import {
$commandPaletteOpen,
@ -59,8 +60,16 @@ import {
import { $bindings } from '@/store/keybinds'
import { openPetGenerate } from '@/store/pet-generate'
import { requestStartWorkSession } from '@/store/projects'
import { $connection } from '@/store/session'
import { runGatewayRestart } from '@/store/system-actions'
import { applyBackendUpdate } from '@/store/updates'
import {
$backendUpdateApply,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateStatus,
requestActiveUpdate
} from '@/store/updates'
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@ -93,6 +102,8 @@ interface PaletteItem {
action?: string
/** Renders a trailing check: this row IS the current setting (theme, mode). */
active?: boolean
/** Muted text beside the label — state the row acts on (a version, a count). */
detail?: string
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@ -218,6 +229,38 @@ const rankGroups = (groups: PaletteGroup[], search: string): PaletteGroup[] => {
// theme lists under both Light and Dark). The id suffix disambiguates.
const paletteValue = (item: PaletteItem): string => `${item.label}\u0001${item.id}`
const PaletteRow = memo(function PaletteRow({
bindings,
item,
onSelectMods,
onSelectItem
}: {
bindings: Record<string, string[]>
item: PaletteItem
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
onSelectItem: (item: PaletteItem) => void
}) {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
keywords={item.keywords}
onMouseDown={onSelectMods}
onSelect={() => onSelectItem(item)}
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
{item.active && <Check className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')} />}
</CommandItem>
)
})
// Hermes session ids: <YYYYMMDD>_<HHMMSS>_<6 hex>. Used to offer a direct
// "Go to session id" jump for ids that aren't in the recent-200 list.
const SESSION_ID_RE = /^\d{8}_\d{6}_[a-f0-9]{6}$/
@ -314,6 +357,35 @@ export function CommandPalette() {
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
// The Update row names the same install the statusbar names — same target
// selection, same resolver. Reduced to the label string: an in-flight apply
// rewrites these stores on every progress line, and only a changed string
// should rebuild the palette's groups.
const connection = useStore($connection)
const desktopVersion = useStore($desktopVersion)
const clientStatus = useStore($updateStatus)
const clientApply = useStore($updateApply)
const backendStatus = useStore($backendUpdateStatus)
const backendApply = useStore($backendUpdateApply)
const updateVersionLabel = useMemo(() => {
const backend = connection?.mode === 'remote'
const apply = backend ? backendApply : clientApply
const status = backend ? backendStatus : clientStatus
return resolveVersionStatus({
applying: apply.applying || apply.stage === 'restart',
behind: status?.behind ?? 0,
copy: t.shell.statusbar,
remote: backend,
restarting: apply.stage === 'restart',
sha: status?.currentSha?.slice(0, 7) ?? null,
target: backend ? 'backend' : 'client',
updateAvailable: status?.updateAvailable,
version: backend ? status?.currentVersion : desktopVersion?.appVersion
}).label
}, [backendApply, backendStatus, clientApply, clientStatus, connection?.mode, desktopVersion?.appVersion, t])
// cmdk's onSelect doesn't forward the triggering event — keep the last
// click/keydown modifiers so session rows can honour ⌘-Enter / ⌘-click.
const lastSelectMods = useRef<{ ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }>({
@ -551,11 +623,12 @@ export function CommandPalette() {
run: () => void runGatewayRestart()
},
{
detail: updateVersionLabel,
icon: Download,
id: 'cc-update-hermes',
keywords: ['update', 'upgrade', 'hermes', 'version', 'system', 'restart'],
label: cc.updateHermes,
run: () => void applyBackendUpdate()
run: () => requestActiveUpdate()
}
]
},
@ -632,7 +705,7 @@ export function CommandPalette() {
]
: [])
]
}, [contributedItems, go, settingsSectionLabel, t, worktrees])
}, [contributedItems, go, settingsSectionLabel, t, updateVersionLabel, worktrees])
// The long, granular lists (settings fields, API keys, MCP servers, archived
// chats) only surface once the user types — otherwise they'd bury the
@ -996,35 +1069,15 @@ export function CommandPalette() {
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onMouseDown={noteSelectMods}
onSelect={() => handleSelect(item)}
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
{item.active && (
<Check
className={cn('size-3.5 shrink-0 text-primary', !combo && !item.to && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
{group.items.map(item => (
<PaletteRow
bindings={bindings}
item={item}
key={item.id}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
/>
))}
</CommandGroup>
))}
</>

View file

@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react'
import { useEffect } from 'react'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
import { refreshActiveProfile } from '@/store/profile'
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
import {
@ -14,10 +16,15 @@ import type { GatewayRequester } from '../types'
// Cron sessions are written by a background scheduler tick, messaging turns by
// the background gateway (Telegram, WeChat, Discord, …) — neither signals the
// desktop websocket, so poll the bounded lists while the app is visible.
// desktop websocket directly. Backends with the change watcher broadcast
// `cron.changed` / `sessions.changed` when those on-disk writes land, so the
// timers below become slow safety-net backstops; against an older backend
// (no `change_events` on gateway.ready) they stay at the legacy cadence.
const CRON_POLL_INTERVAL_MS = 30_000
const CRON_BACKSTOP_INTERVAL_MS = 5 * 60_000
const MESSAGING_POLL_INTERVAL_MS = 10_000
const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
const ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS = 30_000
// Match the TUI's live-session refresh cadence. Auto-compression can rotate a
// stored session id while its turn keeps running; until the next snapshot the
// sidebar row points at the new id while the renderer still knows the old one.
@ -25,6 +32,15 @@ const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000
// alarming (and clicking the row appeared to "fix" it by touching the live
// session). This snapshot is small and already polled at 1.5s by the TUI.
const LIVE_SESSION_STATUS_POLL_INTERVAL_MS = 1_500
// With change events the snapshot re-pulls on every sessions.changed tick, so
// the interval only covers the degraded-socket edge the stream can't replay
// (see rehydrateLiveSessionStatuses) — 30s is plenty for that.
const LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS = 30_000
// Coalesce tick-driven sidebar list refreshes: sessions.changed fires (floored
// to 2s server-side) on every state.db write during a streaming turn, and the
// full list refresh is heavier than the active_list snapshot. Trailing-edge
// scheduled, so the burst's last write always lands.
const SESSIONS_LIST_TICK_GAP_MS = 10_000
interface LiveSessionStatusItem {
id?: string
@ -201,6 +217,10 @@ export function useBackgroundSync({
refreshSessions,
requestGateway
}: BackgroundSyncParams): void {
const changeEventsAvailable = useStore($changeEventsAvailable)
const cronChangeTick = useStore($cronChangeTick)
const sessionsChangeTick = useStore($sessionsChangeTick)
useEffect(() => {
if (gatewayState !== 'open') {
return
@ -229,8 +249,9 @@ export function useBackgroundSync({
// A reconnect loses renderer-only working/attention atoms while the backend
// keeps the actual turns alive. Re-seed from the gateway's in-memory session
// registry immediately, then cheaply poll while visible so a profile switch
// or missed reconnect edge cannot leave running rows dark until clicked.
// registry immediately, then re-pull on every sessions.changed broadcast; a
// slow visible poll remains as the backstop for the degraded-socket edge the
// stream cannot replay (legacy cadence against older backends).
useEffect(() => {
if (gatewayState !== 'open') {
return
@ -260,7 +281,10 @@ export function useBackgroundSync({
}
}
const dispose = visiblePoll(LIVE_SESSION_STATUS_POLL_INTERVAL_MS, () => void refreshLiveStatuses())
const dispose = visiblePoll(
changeEventsAvailable ? LIVE_SESSION_STATUS_BACKSTOP_INTERVAL_MS : LIVE_SESSION_STATUS_POLL_INTERVAL_MS,
() => void refreshLiveStatuses()
)
void refreshLiveStatuses()
@ -268,44 +292,98 @@ export function useBackgroundSync({
cancelled = true
dispose()
}
}, [activeGatewayProfile, gatewayState, requestGateway])
// sessionsChangeTick: each sessions.changed broadcast re-seeds immediately
// via the effect re-run (already coalesced to 2s server-side).
}, [activeGatewayProfile, changeEventsAvailable, gatewayState, requestGateway, sessionsChangeTick])
// sessions.changed also means the *stored* list may have new rows (a cron
// run's session, an inbound messaging turn creating a thread). The full list
// refresh is heavier than the active_list snapshot, so trail it on a gap
// instead of firing per tick. Direct atom subscription: the throttle state
// lives in the effect closure, not in refs synced from renders.
useEffect(() => {
if (gatewayState !== 'open' || !changeEventsAvailable) {
return
}
let lastRunAt = 0
let timer: null | number = null
const run = () => {
lastRunAt = Date.now()
void refreshSessions()
void refreshMessagingSessions()
}
const unsubscribe = $sessionsChangeTick.listen(() => {
const since = Date.now() - lastRunAt
if (since >= SESSIONS_LIST_TICK_GAP_MS) {
run()
} else if (timer === null) {
timer = window.setTimeout(() => {
timer = null
run()
}, SESSIONS_LIST_TICK_GAP_MS - since)
}
})
return () => {
unsubscribe()
if (timer !== null) {
window.clearTimeout(timer)
}
}
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions, refreshSessions])
// Keep the cron-jobs section live without a user action (scheduler ticks in
// the background); re-check on tab re-focus too.
// the background). cron.changed (jobs.json moved: CRUD or a scheduler tick's
// bookkeeping) drives the refresh; the visible poll is the backstop.
useEffect(() => {
if (gatewayState !== 'open') {
return
}
return visiblePoll(CRON_POLL_INTERVAL_MS, () => void refreshCronJobs())
}, [gatewayState, refreshCronJobs])
// Keep the messaging-platform session lists live (inbound turns are written
// by the gateway, not the desktop websocket).
useEffect(() => {
if (gatewayState !== 'open') {
return
if (cronChangeTick > 0) {
void refreshCronJobs()
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [gatewayState, refreshMessagingSessions])
return visiblePoll(
changeEventsAvailable ? CRON_BACKSTOP_INTERVAL_MS : CRON_POLL_INTERVAL_MS,
() => void refreshCronJobs()
)
}, [changeEventsAvailable, cronChangeTick, gatewayState, refreshCronJobs])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
// Only the open messaging transcript needs its own cadence — local chats are
// live over the websocket already. sessions.changed re-pulls it via the tick
// dep; the visible poll is the backstop.
useEffect(() => {
if (gatewayState !== 'open' || !activeIsMessaging) {
return
}
const dispose = visiblePoll(
ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
changeEventsAvailable ? ACTIVE_MESSAGING_SESSION_BACKSTOP_INTERVAL_MS : ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS,
() => void refreshActiveMessagingTranscript()
)
void refreshActiveMessagingTranscript()
return dispose
}, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript])
// sessionsChangeTick: an inbound turn re-pulls the open transcript.
}, [activeIsMessaging, changeEventsAvailable, gatewayState, refreshActiveMessagingTranscript, sessionsChangeTick])
// Messaging session lists against an older backend: no sessions.changed, so
// keep the legacy visible poll. (Event-capable backends fold this into the
// trailing sessions.changed refresh above.)
useEffect(() => {
if (gatewayState !== 'open' || changeEventsAvailable) {
return
}
return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions())
}, [changeEventsAvailable, gatewayState, refreshMessagingSessions])
// A fresh new-session draft (gateway open, no active session) re-pulls the
// model + config so the composer pill reflects the profile default.

View file

@ -29,11 +29,21 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
import { playWakeSound } from '@/lib/wake-sound'
import { $billingSettingsRequest } from '@/store/billing-block'
import { requestVoiceConversationStart } from '@/store/composer'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import {
$activeGatewayProfile,
$freshSessionRequest,
$profileScope,
ensureGatewayProfile,
newSessionInProfile,
normalizeProfileKey,
refreshActiveProfile
} from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
import {
$activeSessionId,
@ -54,6 +64,7 @@ import {
setMessages
} from '@/store/session'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
@ -662,9 +673,39 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const handleGatewayEventWithPlugins = useCallback(
(event: Parameters<typeof handleDesktopGatewayEvent>[0]) => {
emitGatewayEvent(event)
if (event.type === 'wake.detected') {
const payload = event.payload as { profile?: null | string; start_new_session?: boolean } | undefined
// Audible confirmation that the wake registered, before voice capture
// starts. Gated by the shared sound-mute toggle.
playWakeSound()
// Multi-profile routing: a wake phrase enrolled by another profile
// re-homes the gateway to that profile first (live swap — same path
// as clicking it in the profile rail), then opens the fresh session
// and starts voice there.
const targetProfile = payload?.profile?.trim()
const activeProfile = normalizeProfileKey($activeGatewayProfile.get())
if (targetProfile && normalizeProfileKey(targetProfile) !== activeProfile) {
if (payload?.start_new_session !== false) {
newSessionInProfile(targetProfile)
} else {
void ensureGatewayProfile(normalizeProfileKey(targetProfile))
}
} else if (payload?.start_new_session !== false) {
startFreshSessionDraft()
}
requestVoiceConversationStart()
return
}
handleDesktopGatewayEvent(event)
},
[handleDesktopGatewayEvent]
[handleDesktopGatewayEvent, startFreshSessionDraft]
)
useGatewayBoot({
@ -685,6 +726,14 @@ export function ContribWiring({ children }: { children: ReactNode }) {
refreshSessions
})
useEffect(() => {
if (gatewayState === 'open') {
// Status-then-arm, syncing $wakeWord so the composer toggle reflects the
// same listener this auto-arm claims.
void armWakeWord(requestGateway)
}
}, [gatewayState, requestGateway])
// Only the open messaging transcript needs its own poll — local chats are
// live over the websocket already.
const activeIsMessaging =

View file

@ -48,6 +48,7 @@ import { AlertTriangle } from '@/lib/icons'
import { requestModelOptions } from '@/lib/model-options'
import { asText } from '@/lib/text'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { $changeEventsAvailable, $cronChangeTick } from '@/store/live-sync'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope, ALL_PROFILES } from '@/store/profile'
@ -672,10 +673,12 @@ function formatRunTime(seconds?: null | number): string {
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
}
// Runs are produced by the background scheduler tick (no UI signal), so poll
// while the panel is open + on tab re-focus so a fired run shows up within a few
// seconds instead of waiting for a reload.
// Runs are produced by the background scheduler tick. cron.changed /
// sessions.changed broadcasts re-load immediately on event-capable backends
// (the tick dep below), so the poll drops to a slow backstop there; older
// backends keep the legacy cadence.
const RUNS_POLL_INTERVAL_MS = 8000
const RUNS_BACKSTOP_INTERVAL_MS = 60_000
function CronJobRuns({
c,
@ -687,6 +690,8 @@ function CronJobRuns({
onOpenSession?: (sessionId: string) => void
}) {
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
const changeEventsAvailable = useStore($changeEventsAvailable)
const cronChangeTick = useStore($cronChangeTick)
useEffect(() => {
let cancelled = false
@ -706,11 +711,14 @@ function CronJobRuns({
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
void load()
}
}, RUNS_POLL_INTERVAL_MS)
const intervalId = window.setInterval(
() => {
if (document.visibilityState === 'visible') {
void load()
}
},
changeEventsAvailable ? RUNS_BACKSTOP_INTERVAL_MS : RUNS_POLL_INTERVAL_MS
)
const onVisible = () => {
if (document.visibilityState === 'visible') {
@ -725,7 +733,8 @@ function CronJobRuns({
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', onVisible)
}
}, [jobId])
// cronChangeTick: a fired run moves jobs.json bookkeeping → reload now.
}, [changeEventsAvailable, cronChangeTick, jobId])
return (
<div>

View file

@ -13,11 +13,27 @@ import {
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentPropsWithoutRef, ComponentType, SVGProps } from 'react'
import { forwardRef } from 'react'
import { forwardRef, memo } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
// ---------------------------------------------------------------------------
// Photon brand icon — three diagonal rounded bars (the Photon logo mark).
// Rendered at ~14 px inside the PlatformAvatar so the bars are kept thick
// enough to stay legible. At small sizes the bars blend into a distinctive
// silhouette; the wide triangular spacing preserves the logo's identity.
// ---------------------------------------------------------------------------
function PhotonIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<rect height="10" rx="1.25" transform="rotate(15 14 7.5)" width="2.5" x="12.75" y="2.5" />
<rect height="10" rx="1.25" transform="rotate(15 8 13)" width="2.5" x="6.75" y="8" />
<rect height="10" rx="1.25" transform="rotate(15 16 18)" width="2.5" x="14.75" y="13" />
</svg>
)
}
// We render simpleicons.org brand glyphs for platforms whose owners publish a
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
@ -45,6 +61,7 @@ const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
photon: { Icon: PhotonIcon, color: '#6366F1', kind: 'brand' },
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
@ -65,48 +82,50 @@ interface PlatformAvatarProps extends Omit<ComponentPropsWithoutRef<'span'>, 'ch
// component and injects a ref plus pointer/focus/aria handlers onto it. A
// plain function component with no ref/rest forwarding drops all of that
// silently — the tooltip renders but never opens (#67500).
export const PlatformAvatar = forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
{ className, platformId, platformName, style, ...rest },
ref
) {
const spec = PLATFORM_ICONS[platformId]
export const PlatformAvatar = memo(
forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
{ className, platformId, platformName, style, ...rest },
ref
) {
const spec = PLATFORM_ICONS[platformId]
const baseClass = cn(
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
className
)
const baseClass = cn(
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
className
)
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
ref={ref}
style={style}
{...rest}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
}
const { Icon, color } = spec
if (!spec) {
return (
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
className={baseClass}
ref={ref}
style={style}
style={{
// 16% tint of the brand color so the glyph reads against any surface
// without the avatar dominating the row.
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
color,
...style
}}
{...rest}
>
{platformName.charAt(0).toUpperCase()}
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
</span>
)
}
const { Icon, color } = spec
return (
<span
aria-hidden="true"
className={baseClass}
ref={ref}
style={{
// 16% tint of the brand color so the glyph reads against any surface
// without the avatar dominating the row.
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
color,
...style
}}
{...rest}
>
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
</span>
)
})
})
)

View file

@ -1,4 +1,4 @@
import { Fragment, type ReactNode } from 'react'
import { Fragment, memo, type ReactNode } from 'react'
import { TabDropdown } from '@/components/ui/tab-dropdown'
import type { IconComponent } from '@/lib/icons'
@ -94,7 +94,14 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
export const OverlayNavItem = memo(function OverlayNavItem({
active,
icon: Icon,
label,
nested,
onClick,
trailing
}: OverlayNavItemProps) {
return (
<button
className={cn(
@ -121,7 +128,7 @@ export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, tra
{trailing}
</button>
)
}
})
export interface OverlayNavLink {
active: boolean

View file

@ -82,6 +82,33 @@ describe('readProjectDir', () => {
expect(readFileDataUrl).toHaveBeenCalledWith('C:/repo/.gitignore')
})
it('filters gitignored entries when Windows path casing differs across IPC results', async () => {
gitRoot.mockResolvedValue('C:\\Repo')
readDir.mockImplementation(async path => {
if (path === 'c:\\repo\\src') {
return ok([
{ name: 'debug.log', path: 'c:\\repo\\src\\debug.log', isDirectory: false },
{ name: 'keep.ts', path: 'c:\\repo\\src\\keep.ts', isDirectory: false }
])
}
if (path === 'C:/Repo') {
return ok([{ name: '.gitignore', path: 'C:/Repo/.gitignore', isDirectory: false }])
}
if (path === 'C:/Repo/src') {
return ok([])
}
return ok([])
})
readFileDataUrl.mockResolvedValue(dataUrl('src/*.log\n'))
const result = await readProjectDir('c:\\repo\\src', 'c:\\repo')
expect(result.entries.map(entry => entry.name)).toEqual(['keep.ts'])
})
it('does not fetch .gitignore contents when listings do not contain .gitignore', async () => {
gitRoot.mockResolvedValue('/repo')
readDir.mockImplementation(async path => {

View file

@ -32,16 +32,25 @@ function clean(path: string) {
return path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'
}
// Windows path identity is case-insensitive. Fold only comparison keys so the
// relative path returned below keeps the filesystem's original spelling;
// POSIX paths remain case-sensitive.
function comparisonPath(path: string) {
return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') ? path.toLowerCase() : path
}
/** Strict POSIX-style relative path; null if `child` is not inside `root`. */
function relativeTo(root: string, child: string) {
const r = clean(root)
const c = clean(child)
const rKey = comparisonPath(r)
const cKey = comparisonPath(c)
if (c === r) {
if (cKey === rKey) {
return ''
}
return c.startsWith(`${r}/`) ? c.slice(r.length + 1) : null
return cKey.startsWith(`${rKey}/`) ? c.slice(r.length + 1) : null
}
/** Repo-root → repo-root/a → repo-root/a/b → … for every dir between root and `dir`. */

View file

@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
const key = (init: Partial<KeyboardEvent> & { key: string }) =>
({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, type: 'keydown', ...init }) as KeyboardEvent
describe('terminalClipboardIntent', () => {
it('never claims a bare Ctrl+C with nothing selected, on either platform', () => {
for (const isMac of [true, false]) {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: false, isMac })).toBeNull()
}
})
it('copies on Ctrl+C when text is selected, so a selection is never lost to SIGINT', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })).toBe('copy')
})
it('reserves plain Ctrl+C for the shell on macOS, where ⌘C is the copy chord', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: true })).toBeNull()
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: true, isMac: true })).toBe('copy')
})
it('only claims copy when there is something to copy', () => {
expect(terminalClipboardIntent(key({ key: 'c', metaKey: true }), { hasSelection: false, isMac: true })).toBeNull()
expect(
terminalClipboardIntent(key({ ctrlKey: true, key: 'c', shiftKey: true }), { hasSelection: false, isMac: false })
).toBeNull()
})
it('claims paste regardless of selection, since paste has nothing to do with one', () => {
expect(terminalClipboardIntent(key({ key: 'v', metaKey: true }), { hasSelection: false, isMac: true })).toBe(
'paste'
)
expect(
terminalClipboardIntent(key({ ctrlKey: true, key: 'v', shiftKey: true }), { hasSelection: false, isMac: false })
).toBe('paste')
})
it('leaves shell chords alone: bare Ctrl+V, Alt combos, and keyup', () => {
expect(terminalClipboardIntent(key({ ctrlKey: true, key: 'v' }), { hasSelection: false, isMac: false })).toBeNull()
expect(
terminalClipboardIntent(key({ altKey: true, ctrlKey: true, key: 'c' }), { hasSelection: true, isMac: false })
).toBeNull()
expect(
terminalClipboardIntent(key({ key: 'c', metaKey: true, type: 'keyup' }), { hasSelection: true, isMac: true })
).toBeNull()
})
})
describe('mirrorSelection', () => {
const host = () => {
const el = document.createElement('div')
const textarea = document.createElement('textarea')
textarea.className = 'xterm-helper-textarea'
el.appendChild(textarea)
return { el, textarea }
}
it('puts the selection where the OS copy command can find it', () => {
const { el, textarea } = host()
mirrorSelection(el, 'npm run check')
expect(textarea.value).toBe('npm run check')
})
it('clears the mirror when the selection goes away', () => {
const { el, textarea } = host()
mirrorSelection(el, 'something')
mirrorSelection(el, '')
expect(textarea.value).toBe('')
})
it('is a no-op before xterm has mounted its textarea', () => {
expect(() => mirrorSelection(document.createElement('div'), 'text')).not.toThrow()
})
})

View file

@ -0,0 +1,69 @@
// Clipboard keybindings for the GUI terminal.
//
// xterm renders to a canvas, so its selection is not a DOM selection and the
// platform's own copy command has nothing to grab. Two mechanisms fix that:
// this key map (explicit chords) and `mirrorSelection` below (which hands the
// selection to the OS through xterm's hidden helper textarea, so the Edit menu,
// ⌘C on macOS — swallowed by the menu before the renderer sees it — and the
// right-click menu all work).
//
// The chords follow VS Code (terminal.clipboard.contribution.ts): ⌘C/⌘V on
// macOS, Ctrl+Shift+C/V elsewhere, plus plain Ctrl+C as copy ONLY when text is
// selected — the "intelligent Ctrl-C" of Windows Terminal and Tabby. With no
// selection Ctrl+C stays SIGINT, so interrupting a process never breaks.
export type TerminalClipboardIntent = 'copy' | 'paste' | null
export function terminalClipboardIntent(
event: KeyboardEvent,
{ hasSelection, isMac }: { hasSelection: boolean; isMac: boolean }
): TerminalClipboardIntent {
if (event.type !== 'keydown' || event.altKey) {
return null
}
const key = event.key.toLowerCase()
if (isMac) {
if (!event.metaKey || event.ctrlKey || event.shiftKey) {
return null
}
// ⌘C with nothing selected falls through to the shell (⌘ isn't a terminal
// modifier, so it's a no-op there rather than a lost keystroke).
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
}
if (!event.ctrlKey || event.metaKey) {
return null
}
if (event.shiftKey) {
return key === 'c' ? (hasSelection ? 'copy' : null) : key === 'v' ? 'paste' : null
}
// Bare Ctrl+C: copy only when there's a selection to copy, else SIGINT.
return key === 'c' && hasSelection ? 'copy' : null
}
// Hand the terminal's selection to the OS by mirroring it into xterm's hidden
// helper textarea (the same trick xterm uses for Linux middle-click paste,
// CoreBrowserTerminal.ts:531). Without it `webContents.copy()` — what the Edit
// menu, ⌘C, and the right-click Copy item all call — finds no DOM selection and
// copies nothing.
export function mirrorSelection(host: HTMLElement, text: string) {
const textarea = host.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
if (!textarea) {
return
}
if (!text) {
textarea.value = ''
return
}
textarea.value = text
textarea.select()
}

View file

@ -5,11 +5,14 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useEffect, useRef } from 'react'
import { writeClipboardText } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { registerAgentTerminalWriter } from './agent-terminal-stream'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { resolveSurfaceColor, terminalTheme } from './selection'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import { isMacPlatform, resolveSurfaceColor, terminalTheme } from './selection'
// Read-only terminal for an agent background process: a write-only xterm (no PTY,
// no input) fed live by the backend output stream, keyed by process id. Shares
@ -61,6 +64,30 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
term.open(host)
termRef.current = term
// Read-only mirror, but the output is exactly what people want to copy.
// No paste path: this terminal has no PTY to paste into.
const selectionDisposable = term.onSelectionChange(() => mirrorSelection(host, term.getSelection()))
term.attachCustomKeyEventHandler(event => {
const intent = terminalClipboardIntent(event, {
hasSelection: Boolean(term.getSelection()),
isMac: isMacPlatform()
})
if (intent !== 'copy') {
return true
}
event.preventDefault()
void writeClipboardText(term.getSelection()).catch(() => {
// Clipboard unavailable — leave the selection so the user can retry.
})
term.clearSelection()
triggerHaptic('selection')
return false
})
fitRef.current = () => {
if (host.clientWidth > 0 && host.clientHeight > 0) {
try {
@ -94,6 +121,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id:
return () => {
unregister()
unregisterReader()
selectionDisposable.dispose()
observer.disconnect()
term.dispose()
termRef.current = null

View file

@ -7,6 +7,7 @@ import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { writeClipboardText } from '@/components/ui/copy-button'
import { triggerHaptic } from '@/lib/haptics'
import { $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
@ -14,8 +15,10 @@ import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, registerTerminalReader } from './buffer'
import { mirrorSelection, terminalClipboardIntent } from './clipboard'
import {
isAddSelectionShortcut,
isMacPlatform,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
@ -792,12 +795,55 @@ export function useTerminalSession({
const next = term.getSelection()
selectionRef.current = next
selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : ''
// Mirror into xterm's helper textarea so the OS sees a real selection —
// that's what makes the Edit menu, ⌘C, and right-click Copy work over a
// canvas that has no DOM selection of its own.
mirrorSelection(host, next)
setSelection(next)
setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null)
})
cleanup.push(() => selectionDisposable.dispose())
// Copy/paste chords. Returning false stops xterm from also sending the key
// to the PTY; every path that doesn't copy or paste returns true, so plain
// Ctrl+C with no selection still interrupts the running process.
term.attachCustomKeyEventHandler(event => {
const intent = terminalClipboardIntent(event, {
hasSelection: Boolean(term.getSelection()),
isMac: isMacPlatform()
})
if (!intent) {
return true
}
event.preventDefault()
if (intent === 'copy') {
const text = term.getSelection()
// Write through the main process: the renderer's clipboard API throws
// "Write permission denied" whenever the document isn't focused.
void writeClipboardText(text).catch(() => {
// Clipboard unavailable — the selection stays put so the user can retry.
})
term.clearSelection()
triggerHaptic('selection')
return false
}
void (async () => {
const text = (await window.hermesDesktop?.readClipboard?.()) ?? ''
if (text) {
hasSessionActivityRef.current = true
term.paste(text)
}
})()
return false
})
const startSession = () =>
void terminalApi
// Prefer the prior session's last cwd so a reopened tab lands where the

View file

@ -14,7 +14,7 @@ import {
setDefaultReasoningEffort,
setIntroPersonality
} from '@/store/session'
import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
import { applyAutoSpeakFromConfig, applyThinkingSoundFromConfig, applyVoiceStopPhraseFromConfig } from '@/store/voice-prefs'
const DEFAULT_VOICE_SECONDS = 120
const FAST_TIERS = new Set(['fast', 'priority', 'on'])
@ -105,6 +105,8 @@ export function useHermesConfig({ activeSessionIdRef }: HermesConfigOptions) {
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
applyAutoSpeakFromConfig(config)
applyVoiceStopPhraseFromConfig(config)
applyThinkingSoundFromConfig(config)
} catch {
// Config is nice-to-have; chat still works without it.
}

View file

@ -24,6 +24,13 @@ import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { $gateway } from '@/store/gateway'
import { applyGoalStatusText } from '@/store/goals'
import {
notifyCronChanged,
notifyPetChanged,
notifySessionsChanged,
type PetChangeMeta,
setChangeEventsAvailable
} from '@/store/live-sync'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
@ -275,6 +282,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
// Backends with the change watcher broadcast pet/cron/sessions change
// events; consumers demote their legacy polls to slow backstops.
setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events))
return
} else if (event.type === 'skin.changed') {
@ -287,6 +297,25 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
}
return
} else if (event.type === 'pet.changed' || event.type === 'cron.changed' || event.type === 'sessions.changed') {
// Change-watcher broadcasts (server._broadcast_watched_changes): the
// backend's on-disk signature moved. Route to the live-sync ticks the
// former pollers now subscribe to. Only the active profile's changes
// apply — background profile sockets watch their own homes.
const fromActiveChangeProfile =
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
if (fromActiveChangeProfile) {
if (event.type === 'pet.changed') {
notifyPetChanged(payload as PetChangeMeta | undefined)
} else if (event.type === 'cron.changed') {
notifyCronChanged()
} else {
notifySessionsChanged()
}
}
return
} else if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active

View file

@ -12,6 +12,7 @@ import { $notifications, clearNotifications } from '@/store/notifications'
import {
$busy,
$connection,
$currentCwd,
$currentUsage,
$messages,
$sessions,
@ -1624,6 +1625,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
let handle: HarnessHandle | null = null
render(
<Harness
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-a' ? 'rt-session-a' : null)}
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
refreshSessions={async () => undefined}
@ -1655,6 +1657,140 @@ describe('usePromptActions submit / queue drain semantics', () => {
expect($busy.get()).toBe(false)
})
it('a fromQueue drain carrying a stale runtime id re-homes via session.resume instead of landing in the foreground session', async () => {
// The session-switch window this guards: the composer's queue key has
// already flipped to session B (route-driven) while the foreground runtime
// id prop still reads session A (resume-driven, one settle behind). Without
// the central-binding check, prompt.submit fires with session_id=A and B's
// queued prompt — plus its whole answer turn — lands inside A. With no
// binding recorded for B yet, the stale id must be dropped and the drain
// re-homed through the stored-session resume path.
const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] =
[]
const requestGateway = vi.fn(
async (method: string, _params?: Record<string, unknown>) =>
(method === 'session.resume' ? { session_id: 'rt-session-b' } : {}) as never
)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued for B mid-switch', {
fromQueue: true,
sessionId: 'rt-session-a',
storedSessionId: 'stored-session-b'
})
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-session-b',
source: 'desktop'
})
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: 'rt-session-b',
text: 'queued for B mid-switch'
},
1_800_000
)
// The invariant: the stale foreground runtime never receives the prompt.
expect(
requestGateway.mock.calls.every(
([method, params]) =>
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
)
).toBe(true)
expect(
updates.some(update => update.sessionId === 'rt-session-b' && update.storedSessionId === 'stored-session-b')
).toBe(true)
})
it('a fromQueue drain rebinds to the centrally recorded runtime when its explicit id is stale', async () => {
// Same window, but B's runtime binding is already known centrally — the
// drain should adopt the authoritative binding directly (no resume
// round-trip) rather than trusting the leftover foreground id.
const requestGateway = vi.fn(async (_method: string, _params?: Record<string, unknown>) => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
getRuntimeIdForStoredSession={storedId => (storedId === 'stored-session-b' ? 'rt-session-b-live' : null)}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued for B, B already re-bound', {
fromQueue: true,
sessionId: 'rt-session-a',
storedSessionId: 'stored-session-b'
})
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
queued: true,
session_id: 'rt-session-b-live',
text: 'queued for B, B already re-bound'
},
1_800_000
)
expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything())
expect(
requestGateway.mock.calls.every(
([method, params]) =>
method !== 'prompt.submit' || (params as { session_id?: string }).session_id !== 'rt-session-a'
)
).toBe(true)
})
it('a NON-queue explicit target keeps its runtime id even with no central binding recorded', async () => {
// The scoping invariant for the check above. A slash skill dispatch into a
// fresh ⌘T tab passes the same shape a stale drain does — sessionId and
// storedSessionId differ, and the tab has no central binding yet — but its
// two ids were resolved in the same tick, so the explicit target IS
// authoritative. Validating this caller against the (empty) binding would
// null the target and silently drop the kickoff into nowhere.
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
getRuntimeIdForStoredSession={() => null}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('kickoff for the tab', {
sessionId: 'rt-tab',
storedSessionId: 'stored-tab'
})
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
session_id: 'rt-tab',
text: 'kickoff for the tab'
},
1_800_000
)
})
it('a fromQueue drain with null runtime id does NOT land in the foreground session (cross-session leak guard)', async () => {
// The cross-session leak: a background drain fires with sessionId=null
// (the stored session's runtime was reaped by the gateway). Without the
@ -2128,6 +2264,7 @@ describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
$currentCwd.set('')
vi.restoreAllMocks()
})
@ -2190,6 +2327,100 @@ describe('usePromptActions file attachment sync', () => {
})
})
it('uploads Windows file bytes when local mode fronts a POSIX WSL/Docker backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('/root')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
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: '/root/.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
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.submitText('summarize', { attachments: [attachment] })).toBe(true)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Downloads\\report.txt')
expect(calls[0]).toEqual({
method: 'file.attach',
params: {
data_url: 'data:text/plain;base64,aGVsbG8=',
name: 'report.txt',
path: 'C:\\Users\\alice\\Downloads\\report.txt',
session_id: RUNTIME_SESSION_ID
}
})
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:.hermes/desktop-attachments/report.txt\n\nsummarize' }
})
})
it('uses image.attach_bytes for a Windows image when the local backend cwd is POSIX', async () => {
const readFileDataUrl = vi.fn(async () => 'data:image/jpeg;base64,aGVsbG8=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'image.attach_bytes') {
return { attached: true, path: '/root/tmp/photo.jpg' } as never
}
return {} as never
})
const uploaded = await uploadComposerAttachment(
{
id: 'image:photo.jpg',
kind: 'image',
label: 'photo.jpg',
path: 'C:\\Users\\alice\\Pictures\\photo.jpg'
},
{
backendCwd: '/root',
remote: false,
requestGateway,
sessionId: RUNTIME_SESSION_ID
}
)
expect(readFileDataUrl).toHaveBeenCalledWith('C:\\Users\\alice\\Pictures\\photo.jpg')
expect(requestGateway).toHaveBeenCalledWith('image.attach_bytes', {
content_base64: 'aGVsbG8=',
filename: 'photo.jpg',
session_id: RUNTIME_SESSION_ID
})
expect(requestGateway).not.toHaveBeenCalledWith('image.attach', expect.anything())
expect(uploaded.path).toBe('/root/tmp/photo.jpg')
})
it('passes a path-less @file: ref straight through (no path = nothing to upload)', async () => {
// Submit-layer contract: only attachments that carry a `path` are upload
// candidates. A path-less ref (an @-mention/context ref or pasted text)
@ -2237,8 +2468,20 @@ describe('usePromptActions file attachment sync', () => {
expect(calls[0]?.params?.text).toContain('@file:`/Users/mahmoud/Downloads/DEVIS_signed.pdf`')
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
it('passes a Windows path directly for a native Windows local backend', async () => {
$connection.set({ mode: 'local' } as never)
$currentCwd.set('C:\\Users\\alice\\project')
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,c2hvdWxkLW5vdC1iZS1yZWFk')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl }
})
const attachment: ComposerAttachment = {
...fileAttachment(),
path: 'C:\\Users\\alice\\Downloads\\report.txt',
refText: '@file:`C:\\Users\\alice\\Downloads\\report.txt`'
}
const calls: { method: string; params?: Record<string, unknown> }[] = []
@ -2257,11 +2500,12 @@ describe('usePromptActions file attachment sync', () => {
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
const ok = await handle!.submitText('summarize', { attachments: [attachment] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(readFileDataUrl).not.toHaveBeenCalled()
// Native Windows local mode shares the same path namespace.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
@ -2510,6 +2754,13 @@ describe('usePromptActions sleep/wake session recovery', () => {
let handle: HarnessHandle | null = null
render(
<Harness
// The central binding is stale in lockstep with the caller here: the
// sleep/wake reaper only clears the GATEWAY's in-memory session, so
// client-side state still swears by the old runtime id. That is what
// routes this case to the reactive 404→resume→retry path instead of
// the proactive binding check (covered by the cross-session drain
// tests above).
getRuntimeIdForStoredSession={storedId => (storedId === STORED_SESSION_ID ? 'rt-background-stale' : null)}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
@ -3844,7 +4095,7 @@ describe('uploadComposerAttachment remote read failures', () => {
it('turns the raw 16MB IPC cap error into a friendly remote-gateway message', async () => {
// electron/hardening.ts rejects the readFileDataUrl IPC with this exact
// shape when a file exceeds DATA_URL_READ_MAX_BYTES.
// shape when a file exceeds the configured data-URL read cap.
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {

View file

@ -25,6 +25,7 @@ import { clearAllPrompts } from '@/store/prompts'
import {
$busy,
$connection,
$currentCwd,
$messages,
setAwaitingResponse,
setBusy,
@ -76,26 +77,38 @@ interface HandoffResult {
error?: string
}
const WINDOWS_ABSOLUTE_PATH_RE = /^(?:[A-Za-z]:[\\/]|\\\\)/
const POSIX_ABSOLUTE_PATH_RE = /^\/(?!\/)/
// `mode: local` means the gateway was launched locally, not necessarily that
// Electron and the gateway share a filesystem. Windows Desktop can front a
// WSL/Docker backend whose cwd is POSIX, so a Windows host path must cross the
// boundary as bytes just like a remote attachment.
function attachmentPathNeedsUpload(path: string, backendCwd?: null | string): boolean {
return WINDOWS_ABSOLUTE_PATH_RE.test(path.trim()) && POSIX_ABSOLUTE_PATH_RE.test(backendCwd?.trim() || '')
}
/**
* Stage one file/image attachment into the session workspace and return the
* attachment rewritten with the gateway-side ref. Images upload their bytes in
* remote mode (so vision works) and pass the path locally; non-image files
* upload bytes remotely and pass the path locally. Throws on failure so callers
* can surface an error. Shared by submit-time sync, the eager drop-time upload,
* and the message-edit composer drop keep them in lockstep.
* attachment rewritten with the gateway-side ref. Attachments upload their
* bytes for remote gateways and local cross-filesystem backends; otherwise the
* gateway receives the shared local path. Throws on failure so callers can
* surface an error. Shared by submit-time sync, the eager drop-time upload, and
* the message-edit composer drop keep them in lockstep.
*/
export async function uploadComposerAttachment(
attachment: ComposerAttachment,
opts: { remote: boolean; requestGateway: GatewayRequest; sessionId: string }
opts: { backendCwd?: null | string; remote: boolean; requestGateway: GatewayRequest; sessionId: string }
): Promise<ComposerAttachment> {
const { remote, requestGateway, sessionId } = opts
const { backendCwd, remote, requestGateway, sessionId } = opts
const path = attachment.path ?? ''
const label = attachment.label || pathLabel(path)
const uploadBytes = remote || attachmentPathNeedsUpload(path, backendCwd)
if (attachment.kind === 'image') {
let result: ImageAttachResponse
if (remote) {
if (uploadBytes) {
let payload: Awaited<ReturnType<typeof readImageForRemoteAttach>>
try {
@ -138,7 +151,7 @@ export async function uploadComposerAttachment(
// Non-image file.
let dataUrl: string | null = null
if (remote) {
if (uploadBytes) {
try {
dataUrl = await readFileDataUrlForAttach(path)
} catch (err) {
@ -317,7 +330,12 @@ export function usePromptActions({
}
if (attachment.kind === 'image' || attachment.kind === 'file') {
const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId })
const nextAttachment = await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
// Update-only: never resurrect a chip the user removed mid-upload.
if (updateComposerAttachments) {
@ -356,7 +374,14 @@ export function usePromptActions({
try {
// Update-only: if the user removed the chip while this was uploading,
// don't resurrect it — just drop the staged result on the floor.
updateComposerAttachment(await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }))
updateComposerAttachment(
await uploadComposerAttachment(attachment, {
backendCwd: $currentCwd.get(),
remote,
requestGateway,
sessionId
})
)
} catch (err) {
// Leave the chip in place so submit-time sync can retry (or the user can
// remove it) and flag the card; also toast so a hard failure (unreadable

View file

@ -20,7 +20,14 @@ import {
} from '@/store/composer'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { $sessions, resolveComposerSessionKey, setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import {
$sessions,
resolveComposerSessionKey,
setAwaitingResponse,
setBusy,
setMessages,
touchSessionActivity
} from '@/store/session'
import { $sessionStates } from '@/store/session-states'
import type { ClientSessionState } from '../../../types'
@ -191,6 +198,40 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
let sessionId: null | string = options?.sessionId ?? (isBackgroundQueueDrain ? null : activeSessionIdRef.current)
// A QUEUED runtime id is authoritative ONLY while it still belongs to its
// stored session. On a session switch the composer's queue key flips with
// the route while the foreground runtime id lags a resume behind, so a
// drain can fire with storedSessionId=B but sessionId=A-runtime — and the
// prompt.submit below would land B's queued prompt (and its whole answer
// turn) inside A. Verify the pair against the central binding and drop a
// stale queued id: the targetStoredSessionId resume path below then
// rebinds the right runtime, exactly as a background drain with an
// unknown binding does.
//
// Scoped to fromQueue on purpose. Only a drain pairs identifiers from two
// different clocks; every other explicit-target caller resolves both ids
// in the same tick and is authoritative by construction. A slash skill
// dispatch into a fresh ⌘T tab (slash.ts) passes exactly this shape —
// sessionId=tab-runtime, storedSessionId=tab-stored, no central binding
// recorded yet — so an unscoped check would null the target and silently
// drop the kickoff.
//
// The identity pair (storedSessionId === sessionId) is the fresh-chat
// fallback — an unpersisted conversation's queue key IS its runtime id,
// so it has no central binding to check against and is left untouched.
if (
options?.fromQueue &&
options.sessionId &&
options.storedSessionId &&
options.storedSessionId !== options.sessionId
) {
const boundRuntimeId = getRuntimeIdForStoredSession(options.storedSessionId)
if (boundRuntimeId !== options.sessionId) {
sessionId = boundRuntimeId
}
}
// Pin the foreground session context for the whole async submit pipeline.
// Without this, a fast session switch during session.resume / file.attach
// can redirect the user's text into a different chat (#54527). Mutable —
@ -293,7 +334,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// Idempotent optimistic insert — re-running with the resolved sessionId
// after createBackendSessionForSend just overwrites with the same id.
const seedOptimistic = (sid: string) =>
const seedOptimistic = (sid: string) => {
// Recents jump on send — not stream start, not turn resolve.
const activity = bubbleText.trim() ? { preview: bubbleText.trim() } : undefined
touchSessionActivity(sid, activity)
if (targetStoredSessionId && targetStoredSessionId !== sid) {
touchSessionActivity(targetStoredSessionId, activity)
}
updateSessionState(
sid,
state => ({
@ -312,6 +361,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}),
targetStoredSessionId
)
}
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.

View file

@ -1,5 +1,5 @@
import type { AppendMessage } from '@assistant-ui/react'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
@ -12,6 +12,7 @@ import {
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
readFileDataUrlForAttach,
renderRpcResult,
slashStatusText,
visibleUserIndexAtOrdinal,
@ -86,6 +87,32 @@ describe('friendlyRemoteAttachError', () => {
})
})
describe('readFileDataUrlForAttach', () => {
it('prefers the attachment-specific desktop reader over the preview reader', async () => {
const previewReader = vi.fn(async () => 'preview')
const attachmentReader = vi.fn(async () => 'data:application/zip;base64,UEs=')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: previewReader, readFileDataUrlForAttach: attachmentReader }
})
await expect(readFileDataUrlForAttach('/tmp/archive.zip')).resolves.toBe('data:application/zip;base64,UEs=')
expect(attachmentReader).toHaveBeenCalledWith('/tmp/archive.zip')
expect(previewReader).not.toHaveBeenCalled()
})
it('falls back to the preview reader on older shells', async () => {
const previewReader = vi.fn(async () => 'data:text/plain;base64,YQ==')
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: previewReader }
})
await expect(readFileDataUrlForAttach('/tmp/note.txt')).resolves.toBe('data:text/plain;base64,YQ==')
expect(previewReader).toHaveBeenCalledWith('/tmp/note.txt')
})
})
describe('slashStatusText', () => {
it('joins command and trimmed output', () => {
expect(slashStatusText('/model', ' gpt ')).toBe('slash:/model\ngpt')

View file

@ -155,8 +155,10 @@ export async function readImageForRemoteAttach(
// 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).
// Prefer the attach-specific IPC (256 MiB) so remote uploads are not stuck on
// the preview/Settings default; fall back for older Electron shells.
export async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
const reader = window.hermesDesktop?.readFileDataUrlForAttach ?? window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
@ -167,13 +169,12 @@ export async function readFileDataUrlForAttach(filePath: string): Promise<string
return dataUrl || null
}
// The readFileDataUrl IPC base64-loads the whole file into memory and is
// hard-capped (DATA_URL_READ_MAX_BYTES, 16 MB) in electron/hardening.ts, which
// rejects with a raw "file is too large (N bytes; limit M bytes)" string. In
// remote mode every attachment's bytes go through that read, so a big file
// surfaces that internal message verbatim in the failure toast. Translate it
// into a friendly "too large to upload to the remote gateway" line, parsing the
// limit out of the message so it tracks the real cap. Non-cap errors pass
// The attach/preview IPC base64-loads the whole file into memory and rejects
// with a raw "file is too large (N bytes; limit M bytes)" string when over
// cap. In remote mode every attachment's bytes go through that read, so a big
// file surfaces that internal message verbatim in the failure toast. Translate
// it into a friendly "too large to upload to the remote gateway" line, parsing
// the limit out of the message so it tracks the real cap. Non-cap errors pass
// through unchanged.
export function friendlyRemoteAttachError(err: unknown, label: string): Error {
const message = err instanceof Error ? err.message : String(err)

View file

@ -558,6 +558,7 @@ export function useSessionActions({
resetViewSync()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// A session is EITHER the main thread OR a tile — never both. openSessionTile
// enforces this from the tile side (it refuses to tile the selected session);
// this enforces it from the main side. Loading an existing session into main
@ -570,6 +571,7 @@ export function useSessionActions({
if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) {
closeSessionTile(storedSessionId)
}
// Optimistically clear any prior resume-failure latch for this session:
// we're attempting a fresh resume, so the self-heal in use-route-resume
// must not keep treating it as stranded. It's re-armed below only if THIS

View file

@ -0,0 +1,114 @@
import { useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
/**
* Free-input combobox for open-world fields (voice/model names): a plain
* Input the user can type anything into, plus a dropdown listing ALL known
* options.
*
* Replaces the old `<Input list="…">` + `<datalist>` rendering for
* FREE_INPUT_KEYS: native datalists filter by the field's current value, so a
* field already holding a valid option (e.g. `gpt-4o-mini-tts`) suggested
* only that one entry users couldn't discover the other models/voices at
* all (and on some platforms datalists barely render). Suggestions filter by
* substring while typing, but an exact-match value shows the full list so an
* already-configured field still exposes every alternative.
*/
export function ComboboxInput({
value,
onChange,
options,
optionLabels,
placeholder,
className
}: {
value: string
onChange: (value: string) => void
options: string[]
optionLabels?: Record<string, string>
placeholder?: string
className?: string
}) {
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const query = value.trim().toLowerCase()
const isExact = options.some(option => option.toLowerCase() === query)
const visible = query && !isExact ? options.filter(option => option.toLowerCase().includes(query)) : options
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<div className={cn('relative', className)}>
<Input
className="w-full pr-7"
onChange={e => {
onChange(e.target.value)
if (!open) {
setOpen(true)
}
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') {
setOpen(false)
}
}}
placeholder={placeholder}
ref={inputRef}
value={value}
/>
<button
aria-label="Show options"
className="absolute inset-y-0 right-1.5 flex items-center text-muted-foreground"
onClick={() => {
setOpen(current => !current)
inputRef.current?.focus()
}}
tabIndex={-1}
type="button"
>
<Codicon name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] p-0"
onOpenAutoFocus={e => e.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
{visible.length > 0 && (
<CommandGroup>
{visible.map(option => (
<CommandItem
key={option}
onSelect={() => {
onChange(option)
setOpen(false)
}}
value={option}
>
<Codicon
className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')}
name="check"
/>
<span className="truncate">{optionLabels?.[option] ?? option}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -9,10 +9,12 @@ import { prettyName } from '@/lib/text'
import { cn } from '@/lib/utils'
import type { ConfigFieldSchema } from '@/types/hermes'
import { ComboboxInput } from './combobox-input'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants'
import { FallbackModelsField } from './fallback-models-field'
import { fieldCopyForSchemaKey } from './field-copy'
import { ListRow } from './primitives'
import { SearchableSelect } from './searchable-select'
/**
* One generic config row: label + description resolved from the i18n field
@ -93,29 +95,38 @@ export function ConfigField({
const selectOptions = enumOptions ?? (schema.type === 'select' ? (schema.options ?? []).map(String) : undefined)
// Large closed-world lists (e.g. ~590 IANA timezones) get a searchable
// Popover + cmdk combobox instead of a closed Select dropdown. The schema
// opt-in via `searchable: true` keeps this deterministic — no field
// accidentally triggers based on dynamic option count.
if (selectOptions && schema.searchable) {
return row(
<SearchableSelect
clearLabel={schema.clearable ? c.systemDefault : undefined}
emptyMessage={c.noResults}
onChange={next => onChange(next)}
options={selectOptions.filter(o => o !== '')}
placeholder={c.searchPlaceholder}
value={String(value ?? '')}
/>
)
}
// Voice/model name fields are open-world (custom voice IDs, cloned voices,
// brand-new model names) — render a free-input combobox where the known
// options are datalist suggestions instead of a closed Select gate.
// options are dropdown suggestions instead of a closed Select gate. The old
// native <datalist> filtered by the current value, so a field already set
// to a valid option showed only that single suggestion.
if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) {
const datalistId = `config-field-options-${schemaKey.replace(/\./g, '-')}`
return row(
<>
<Input
className={CONTROL_TEXT}
list={datalistId}
onChange={e => onChange(e.target.value)}
placeholder={c.notSet}
value={String(value ?? '')}
/>
<datalist id={datalistId}>
{selectOptions
.filter(option => option !== '')
.map(option => (
<option key={option} label={optionLabels?.[option]} value={option} />
))}
</datalist>
</>
<ComboboxInput
className={CONTROL_TEXT}
onChange={onChange}
optionLabels={optionLabels}
options={selectOptions.filter(o => o !== '')}
placeholder={c.notSet}
value={String(value ?? '')}
/>
)
}

View file

@ -5,8 +5,19 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import {
$dataUrlReadMaxMb,
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB,
refreshDataUrlReadMaxMb,
setDataUrlReadMaxMb
} from '@/store/data-url-read-max'
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
import { notify, notifyError } from '@/store/notifications'
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
@ -21,7 +32,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
import { MemoryConnect } from './memory/connect'
import { ProviderConfigPanel } from './memory/provider-config-panel'
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
import { EmptyState, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { EmptyState, ListRow, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
import { QuickEntrySettings } from './quick-entry-settings'
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
@ -305,9 +316,13 @@ export function ConfigSettings({
<QuickEntrySettings />
</>
)}
{visibleFields.length === 0 ? (
{/* Device-local attach/preview byte cap (main-process IPC guard). Chat is
where image-attachment behavior already lives, so this sits above the
schema fields for that section. */}
{activeSectionId === 'chat' ? <AttachmentSizeSetting /> : null}
{visibleFields.length === 0 && activeSectionId !== 'chat' ? (
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
) : (
) : visibleFields.length === 0 ? null : (
<div className="grid gap-1">
{visibleFields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
@ -345,3 +360,73 @@ export function ConfigSettings({
</SettingsContent>
)
}
/** Free-form MB cap for Desktop's data-URL attach/preview path (main-process). */
function AttachmentSizeSetting() {
const { t } = useI18n()
const c = t.settings.config
const stored = useStore($dataUrlReadMaxMb)
const [draft, setDraft] = useState(String(stored))
useEffect(() => {
void refreshDataUrlReadMaxMb()
}, [])
useEffect(() => {
setDraft(String(stored))
}, [stored])
const commit = () => {
// An empty draft means "reset to the default", not the 1 MB floor
// (Number('') === 0 would otherwise clamp down to the floor).
const applied = draft.trim() === '' ? DATA_URL_READ_DEFAULT_MAX_MB : clampDataUrlReadMaxMb(draft)
// Unchanged: snap the draft back to the stored value and skip the
// pointless IPC write + haptic.
if (applied === stored) {
setDraft(String(stored))
return
}
void setDataUrlReadMaxMb(applied).then(next => {
setDraft(String(next))
// On a bridge write failure the store keeps the old value; only
// celebrate when the new cap actually landed.
if (next === applied) {
triggerHaptic('selection')
}
})
}
return (
<ListRow
action={
<div className="flex items-center gap-2">
<Input
aria-label={c.attachmentSizeLabel}
className="w-20"
inputMode="numeric"
max={DATA_URL_READ_MAX_MAX_MB}
min={DATA_URL_READ_MIN_MAX_MB}
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}}
type="number"
value={draft}
/>
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{c.attachmentSizeUnit}
</span>
</div>
}
description={c.attachmentSizeDesc}
title={c.attachmentSizeTitle}
/>
)
}

View file

@ -263,8 +263,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
// Speech-to-text backends — kept in sync with the stt block in
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
// gpt-4o-mini-tts voice set (the tts-1 era stopped at shimmer). Free-input
// field — the list is suggestions, not a gate (see FREE_INPUT_KEYS).
// OpenAI TTS voices — the union across models (per the OpenAI TTS API
// docs). Model-specific narrowing happens in enumOptionsFor():
// tts-1 / tts-1-hd support 9 voices; gpt-4o-mini-tts supports all 13.
// Free-input field — the list is suggestions, not a gate (FREE_INPUT_KEYS).
'tts.openai.voice': [
'alloy',
'ash',
@ -349,7 +351,7 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'kittentts',
'piper'
],
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'],
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe', 'gpt-transcribe'],
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
@ -564,7 +566,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
repoScanRoots: 'Folders to scan. Leave empty to scan your home directory.',
repoScanExcludePaths: 'Folders and their descendants to skip during repository discovery.'
},
timezone: 'Used when Hermes needs local time context. Blank uses the system timezone.',
timezone: 'IANA timezone identifier. Blank uses the system timezone.',
agent: {
imageInputMode: 'Controls how image attachments are sent to the model.',
maxTurns: 'Upper bound for tool-calling turns before Hermes stops a run.'

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