Merge branch 'main' into relay-ws-hardening

#85796 (live-cards gateway half) landed on main and touches the same
two files. Resolutions:

- adapter.py __init__: union — the dedupe seen-set and the live-cards
  draft/seal caches are independent sibling attributes.
- ws_transport.py _request_response: keep main's ambiguous-timeout
  contract AND this branch's raising-write catch, composed: a raise
  from the WRITE itself means the frame never reached the wire
  (definite non-delivery, no flag), while a failure surfaced after the
  frame was sent carries ambiguous=True like the timeout — tracked via
  a frame_sent marker.
This commit is contained in:
Ben Barclay
2026-08-20 15:54:43 +10:00
783 changed files with 52810 additions and 16345 deletions
@@ -24,6 +24,12 @@ outputs:
docker_meta:
description: Docker setup and meta files have changed.
value: ${{ steps.classify.outputs.docker_meta }}
docker:
description: Files included in the docker image have changed.
value: ${{ steps.classify.outputs.docker }}
nix:
description: Run `nix flake check` (flake inputs, or any product Python change).
value: ${{ steps.classify.outputs.nix }}
site:
description: Build the Docusaurus docs site.
value: ${{ steps.classify.outputs.site }}
+6 -6
View File
@@ -52,14 +52,14 @@ jobs:
- name: Decide whether to build
id: gate
env:
# python_prod (not python): the image copies installed code, never
# tests/, so tests-only PRs skip the build.
PYTHON_PROD: ${{ steps.classify.outputs.python_prod }}
FRONTEND: ${{ steps.classify.outputs.frontend }}
DOCKER_META: ${{ steps.classify.outputs.docker_meta }}
# The docker lane derives from python_prod (not python: the image
# copies installed code, never tests/, so tests-only PRs skip the
# build), frontend and docker_meta. classify_changes.py owns the
# formula so this gate and the nix lane cannot drift apart.
DOCKER: ${{ steps.classify.outputs.docker }}
run: |
set -euo pipefail
if [ "$PYTHON_PROD" = "true" ] || [ "$FRONTEND" = "true" ] || [ "$DOCKER_META" = "true" ]; then
if [ "$DOCKER" = "true" ]; then
echo "build=true" >> "$GITHUB_OUTPUT"
else
echo "build=false" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
RUN_INFO=$(gh run list \
--repo "$REPO" \
--commit "$HEAD_SHA" \
--workflow ci.yml \
--workflow ci.yaml \
--limit 1 \
--json databaseId,status \
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
+117
View File
@@ -0,0 +1,117 @@
name: Nix flake check
# Builds every output of the flake: the package, the devShell, and the 21
# checks under nix/checks.nix — module evaluation, option parity, .env
# assembly, service argv, and the rest.
#
# This workflow owns its triggers and ci.yml does not call it, for the reason
# docker.yml gives: a reusable-workflow call holds the caller run in progress
# for the full build, and GitHub refuses `gh run rerun` on a run that is still
# in progress. One slow advisory job in the CI lane blocks every rerun of the
# fast required jobs beside it. A separate run reruns and cancels on its own.
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
# PR runs collapse to the newest commit. A push to main is never cancelled:
# each one saves the store cache that later PRs restore from, so cancelling a
# merge would leave the next PR to build from nothing.
concurrency:
group: nix-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# A `paths:` filter cannot gate this workflow correctly. The flake packages
# the product, and nine of the checks then run the built binary, so a change
# to hermes_cli/ alone can fail `nix flake check` without touching one file
# under nix/. The `nix` lane therefore follows python_prod as well as the
# flake inputs. On push the classifier fails open and every lane is true.
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
nix: ${{ steps.classify.outputs.nix }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ github.token }}
flake-check:
name: nix flake check
needs: [detect]
if: needs.detect.outputs.nix == 'true'
# The build compiles the package and its whole dependency closure, so this
# is minutes and not seconds when the cache misses.
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Nix
uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0
with:
extra_nix_config: |
experimental-features = nix-command flakes
# A store path that does not substitute is a cache miss and not a
# build failure. Build it here instead.
fallback = true
# Each source archive is fetched one time in a run, and not one
# time for each evaluation.
tarball-ttl = 3600
# Restores /nix/store from the GitHub Actions cache. The store holds the
# whole dependency closure, so a hit turns a build of several minutes
# into a short evaluation.
#
# The Magic Nix Cache is not an option here. Its free tier ended in
# February 2025 with the GitHub cache API that it was built on. This
# action uses the current API and needs no account and no secret.
- name: Restore and save the Nix store
uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7
with:
# The closure changes when the flake inputs change or when the
# dependencies of the project change. The key hashes both, so an
# edit to the source alone keeps the hit.
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.lock', 'nix/**', 'pyproject.toml', 'uv.lock') }}
# On a miss, restore the newest store for this runner. Most of the
# closure — Python, node, each transitive library — survives a bump
# of the lockfile, so an old store still removes most of the work.
restore-prefixes-first-match: nix-${{ runner.os }}-
# Save from main only. A cache that a PR writes is visible to that
# PR alone and never to another branch, so a save there spends the
# 10 GB quota of the repository and helps no later run. A PR still
# restores: it reads the cache that the merge to main wrote. This is
# the same rule that docker.yml applies to `cache-to`.
save: ${{ github.event_name != 'pull_request' }}
# Collect garbage before the save, so the store stays inside the
# 10 GB quota of the repository. Without a limit the store grows at
# each merge until GitHub removes the entry, and the next PR then
# gets nothing. This number is the size of the store and not the
# size of the compressed archive.
gc-max-store-size-linux: 5G
# Delete the caches that this key replaces. GitHub removes caches by
# least recent use across the whole repository, so a Nix store that
# is never purged pushes out the caches of the other workflows.
purge: true
purge-prefixes: nix-${{ runner.os }}-
purge-created: 0
purge-primary-key: never
- name: nix flake check
# --print-build-logs: a check that fails then prints the assertion
# that failed, and not only the derivation that failed to build.
run: nix flake check --print-build-logs
+41
View File
@@ -1342,6 +1342,47 @@ while the agent is blocked (e.g. approval prompts) MUST bypass BOTH
guards and be dispatched inline, not via `_process_message_background()`
(which races session lifecycle).
### Streaming delivery contract (stream-is-the-message adapters) — duplicate-final class
Adapters with `draft_stream_is_message = True` (relay Slack native streaming)
keep ONE cumulative native stream per turn; the stream IS the final message.
Four invariants, each learned from a live duplicate-final incident (NS-658
canary ledger, hermes#85796 / gateway-gateway#210). Violating any of them
re-creates a duplicate or a frozen stream:
1. **Draft frames must be prefix-stable.** The connector computes append-only
deltas: frame N must be a string prefix of frame N+1. NEVER mutate draft
frames per-tick — no fence-closing (`ensure_closed_code_fences`), no cursor
suffix, no segment-state resets at tool boundaries, no mrkdwn conversion.
Any non-prefix frame triggers a whole-snapshot re-append on the platform
("stacked copies"). The finalize path may still transform the real final.
2. **The consumer declares the final; the adapter never guesses.**
`finish(final_text)` carries the completed `final_response` (verifier
footer, completion explainer included) as the authoritative finalize
payload. New post-stream response augmentation MUST ride this payload —
if it mutates `final_response` after the stream sealed, it re-opens the
#11 bug (`delivered_final_matches` mismatch → corrective duplicate send).
3. **Interim sends must carry `_interim_send` metadata.** Any consumer-side
`adapter.send()` that is NOT the turn-final (commentary, segment-tail
flushes) must set `metadata["_interim_send"] = True`, or the relay
adapter's seal-interception will seal the live stream with interim text.
Seal-interception exists at BOTH egress doors (`send()` AND
`send_for_platform()`); a new egress door needs the same two checks.
4. **Reconcile by edit, never by plain send.** Any lane that delivers a final
beside an already-sealed stream (queued follow-ups, media-accompanied
finals, future lanes) must first try `edit_message` on the consumer's
`message_id`; plain `send()` is the fallback only when no editable message
exists. A sealed native stream is a regular message — `chat.update` on it
works (live-verified).
Contract tests: `tests/gateway/test_stream_final_contract.py` (all four
invariants, mutation-checked). Slack streaming API ground truth (live-probed,
also encoded in connector comments/tests): `chat.*Stream` speaks STANDARD
markdown, not mrkdwn; `stopStream.markdown_text` APPENDS (never replaces);
`startStream`/`stopStream` are rate-limit Tier 2 (~20/min).
Guard style note: check `draft_stream_is_message` with `is True` — MagicMock
adapters in older tests auto-create truthy attributes.
### Squash merges from stale branches silently revert recent fixes
Before squash-merging a PR, ensure the branch is up to date with `main`
(`git fetch origin main && git reset --hard origin/main` in the worktree,
+57
View File
@@ -490,6 +490,25 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
agent.request_overrides = overrides
def _normalize_run_budget_seconds(value) -> Optional[float]:
"""Normalize a wall-clock run budget value to a positive float or None.
None / absent / non-numeric / non-positive all resolve to ``None``
(feature off) so a malformed config value can never activate the
deadline machinery, only leave it dormant. ``bool`` is rejected because
YAML ``true`` would otherwise become a 1-second budget.
"""
if value is None or isinstance(value, bool):
return None
try:
seconds = float(value)
except (TypeError, ValueError):
return None
if seconds != seconds or seconds <= 0: # NaN or non-positive
return None
return seconds
def init_agent(
agent,
base_url: str = None,
@@ -529,6 +548,7 @@ def init_agent(
read_preview_callback: callable = None,
read_window_below_callback: callable = None,
setup_mcp_callback: callable = None,
tour_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
@@ -559,6 +579,7 @@ def init_agent(
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
run_budget_seconds: Optional[float] = None,
fallback_model: Dict[str, Any] = None,
credential_pool=None,
checkpoints_enabled: bool = False,
@@ -824,6 +845,7 @@ def init_agent(
agent.read_preview_callback = read_preview_callback
agent.read_window_below_callback = read_window_below_callback
agent.setup_mcp_callback = setup_mcp_callback
agent.tour_callback = tour_callback
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback
agent.interim_assistant_callback = interim_assistant_callback
@@ -911,6 +933,10 @@ def init_agent(
# Model response configuration
agent.max_tokens = max_tokens # None = use model default
agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter)
# Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in).
# Read once at init; switch_model / try_activate_fallback / restore
# keep it in sync with the active provider.
agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config()
agent.service_tier = service_tier
agent.request_overrides = dict(request_overrides or {})
agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns
@@ -966,6 +992,17 @@ def init_agent(
agent._budget_exhausted_injected = False
agent._budget_grace_call = False
# Optional wall-clock run budget (seconds per run_conversation turn).
# Explicit constructor arg wins; else resolved from config.yaml
# (agent.run_budget_seconds) further below. None = feature fully off:
# no clock reads, no injection, no stale-timeout capping.
agent.run_budget_seconds = _normalize_run_budget_seconds(run_budget_seconds)
# Wall-clock start of the CURRENT run_conversation turn. Set by
# turn_context.prepare_turn when a run budget is active; None otherwise.
agent._run_budget_started_at = None
# One-shot latch for the 80% wrap-up notice (reset each turn).
agent._run_budget_wrapup_injected = False
# Activity tracking — updated on each API call, tool execution, and
# stream chunk. Used by the gateway timeout handler to report what the
# agent was doing when it was killed, and by the "still working"
@@ -1890,6 +1927,20 @@ def init_agent(
_agent_section = {}
agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto")
# Execution-discipline guidance gate: "auto" (default — matches
# EXECUTION_GUIDANCE_MODELS), true (always), false (never), or list of
# model-name substrings. Independent of tool_use_enforcement — see
# agent/system_prompt.py for the injection gate.
agent._execution_guidance = _agent_section.get("execution_guidance", "auto")
# Wall-clock run budget from config (agent.run_budget_seconds) — only
# consulted when the constructor arg was not given. Absent/None/invalid
# keeps the feature fully off (zero behavior change in the default path).
if agent.run_budget_seconds is None:
agent.run_budget_seconds = _normalize_run_budget_seconds(
_agent_section.get("run_budget_seconds")
)
# Empty-response retry guard config (NS-503): additive
# ``agent.empty_response_guard`` subsection. Resolution is tolerant —
# a malformed section falls back to the schema defaults (guard on,
@@ -1906,6 +1957,11 @@ def init_agent(
# conversation loop's intent-ack block.
agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto")
# Runtime anti-stall guards (identical-call loop-breaker notice on tool
# results + continue-intent extension of the empty-response recovery).
# Single boolean gate, default True. Notice-only — never blocks a call.
agent._stall_guards = bool(_agent_section.get("stall_guards", True))
# Universal task-completion guidance toggle. Default True. Surfaced
# as a separate flag from tool_use_enforcement because the guidance
# applies to ALL models, not just the model families enforcement
@@ -2949,6 +3005,7 @@ def init_agent(
"client_kwargs": dict(agent._client_kwargs),
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False),
# Context engine state that _try_activate_fallback() overwrites.
# Use getattr for model/base_url/api_key/provider since plugin
# engines may not have these (they're ContextCompressor-specific).
+57 -1
View File
@@ -99,7 +99,7 @@ def _ra():
AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset(
{"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "read_window_below", "setup_mcp", "delegate_task"}
{"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "read_window_below", "setup_mcp", "tour", "delegate_task"}
)
@@ -1349,6 +1349,7 @@ def try_recover_primary_transport(
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent.api_key = rt["api_key"]
agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False)
if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client
@@ -1579,6 +1580,7 @@ def restore_primary_runtime(agent) -> bool:
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent.api_key = rt["api_key"]
agent._reasoning_echo_flag = rt.get("reasoning_echo_flag", False)
agent._client_kwargs = dict(rt["client_kwargs"])
agent._use_prompt_caching = rt["use_prompt_caching"]
# Default to native layout when the restored snapshot predates the
@@ -2652,6 +2654,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"_anthropic_base_url",
"_is_anthropic_oauth",
"_config_context_length",
"_reasoning_echo_flag",
)
}
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
@@ -2685,6 +2688,9 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent.model = new_model
agent.provider = new_provider
agent.requested_provider = new_provider
# Re-read reasoning_echo from config so the flag reflects the new
# primary model's setting (see _reasoning_echo_opt_in).
agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config()
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
@@ -2970,6 +2976,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
"use_prompt_caching": agent._use_prompt_caching,
"use_native_cache_layout": agent._use_native_cache_layout,
"reasoning_config": dict(agent.reasoning_config) if getattr(agent, "reasoning_config", None) else None,
"reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False),
"compressor_model": getattr(_cc, "model", agent.model) if _cc else agent.model,
"compressor_base_url": getattr(_cc, "base_url", agent.base_url) if _cc else agent.base_url,
"compressor_api_key": getattr(_cc, "api_key", "") if _cc else "",
@@ -3200,6 +3207,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
question=next_args.get("question", ""),
choices=next_args.get("choices"),
multi_select=next_args.get("multi_select", False),
questions=next_args.get("questions"),
callback=agent.clarify_callback,
),
next_args,
@@ -3235,6 +3243,23 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
),
next_args,
)
elif function_name == "tour":
def _execute(next_args: dict) -> Any:
from tools.tour_tool import tour_tool as _tour_tool
return _finish_agent_tool(
_tour_tool(
action=next_args.get("action", ""),
surface=next_args.get("surface"),
selector=next_args.get("selector"),
title=next_args.get("title"),
text=next_args.get("text"),
side=next_args.get("side"),
steps=next_args.get("steps"),
step_index=next_args.get("step_index"),
callback=getattr(agent, "tour_callback", None),
),
next_args,
)
elif function_name == "setup_mcp":
def _execute(next_args: dict) -> Any:
from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool
@@ -3838,6 +3863,37 @@ def looks_like_codex_intermediate_ack(
return user_targets_workspace or assistant_targets_workspace
# Conservative "trailing continue-intent" detector for the said-continue-but-
# stopped stall guard (agent.stall_guards). Matches only when the message TAIL
# announces an immediate next action ("Let me now…", "I will now…",
# "Next, I…"), which is the observed stall shape: the model narrates the next
# step and then ends the turn with no tool call. Kept deliberately narrow so
# ordinary answers that merely contain "I will" mid-sentence never trip it.
_TRAILING_CONTINUE_INTENT_RE = re.compile(
r"(?:\blet me now\b|\bi(?:['\u2019])?ll now\b|\bi will now\b"
r"|\bnow i(?:['\u2019]ll| will)\b|\bnext[,:] i\b)"
r"[^.!?\n]{0,100}[.:\u2026]?\s*$",
re.IGNORECASE,
)
# Content longer than this is a substantive reply, not a dangling ack.
_TRAILING_CONTINUE_INTENT_MAX_CHARS = 400
def trailing_continue_intent(text: str) -> bool:
"""Whether ``text`` is a short reply ENDING on an announced next action.
Used by the stall-guard extension of the intent-ack continuation path in
``agent.conversation_loop``: when a turn is about to end with this shape
(no tool calls, short content, trailing intent), the loop re-prompts via
the existing bounded continuation mechanism instead of stopping.
"""
t = (text or "").strip()
if not t or len(t) > _TRAILING_CONTINUE_INTENT_MAX_CHARS:
return False
return bool(_TRAILING_CONTINUE_INTENT_RE.search(t[-160:]))
def intent_ack_continuation_mode(agent) -> str:
"""Classify the resolved intent-ack continuation mode for this turn.
+197 -6
View File
@@ -1551,10 +1551,16 @@ class _CodexCompletionsAdapter:
# Codex backend, which rejects e.g. {"effort": null}
# with a 400.
effort = reasoning_cfg.get("effort") or "medium"
# Codex backend rejects "minimal"; clamp to "low" to
# match the main-agent Codex transport behavior.
if effort == "minimal":
effort = "low"
# Same declared vocabulary + shared clamp as the main
# Codex transport (agent.reasoning_effort): per-model —
# "max" is gpt-5.6-only, "minimal"/"ultra" always
# rejected (live-verified, #68365).
from agent.reasoning_effort import (
clamp_effort,
codex_supported_efforts,
)
effort = clamp_effort(effort, codex_supported_efforts(model))
resp_kwargs["reasoning"] = {
"effort": effort,
"summary": "auto",
@@ -1956,6 +1962,39 @@ class AsyncCodexAuxiliaryClient:
self._real_client = sync_wrapper._real_client
def _translate_anthropic_response_format(
anthropic_kwargs: Dict[str, Any], response_format: Any,
) -> None:
"""Merge an OpenAI response format into Anthropic ``output_config``."""
if not isinstance(response_format, dict):
return
format_type = response_format.get("type")
if format_type == "json_schema":
json_schema = response_format.get("json_schema")
if not isinstance(json_schema, dict) or "schema" not in json_schema:
return
native_format = {
"type": "json_schema",
"schema": json_schema["schema"],
}
elif format_type == "json_object":
# Anthropic SDK 0.87.0 exposes only JSONOutputFormatParam, whose
# required type is ``json_schema``; it has no schema-less JSON mode.
native_format = {
"type": "json_schema",
"schema": {"type": "object"},
}
else:
return
output_config = anthropic_kwargs.get("output_config")
if not isinstance(output_config, dict):
output_config = {}
anthropic_kwargs["output_config"] = output_config
output_config["format"] = native_format
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
@@ -2056,18 +2095,38 @@ class _AnthropicCompletionsAdapter:
# form is the documented Anthropic SDK passthrough for non-standard
# request body keys; merge on top of whatever build_anthropic_kwargs
# already produced (e.g. fast-mode ``speed``) so call-time settings
# survive. Two exclusions:
# survive. Three exclusions:
# - ``reasoning``: the OpenAI-shaped config dict is TRANSLATED into
# the native ``thinking`` field above (build_anthropic_kwargs);
# forwarding the raw field alongside would double-specify
# reasoning and 400 on strict gateways.
# - ``response_format``: the OpenAI structured-output shape is
# TRANSLATED into top-level ``output_config.format`` below;
# forwarding the raw field 400s on strict Anthropic gateways.
# - ``_``-prefixed keys: private Hermes plumbing (_reasoning_config
# et al.), never wire fields.
caller_extra_body = kwargs.get("extra_body")
# A top-level ``response_format`` kwarg (the OpenAI SDK's documented
# call shape) must get the same translation as the extra_body form.
# The adapter builds the Messages body from a fixed allow-list of
# kwargs, so before this an unrecognized top-level kwarg was dropped
# on the floor: the request succeeded but the schema contract
# silently became prompt compliance (#85626 review, point 2). When
# both shapes are present, the extra_body form wins — it is the shape
# every in-tree caller uses.
top_level_response_format = kwargs.get("response_format")
if top_level_response_format is not None:
_translate_anthropic_response_format(
anthropic_kwargs, top_level_response_format,
)
if caller_extra_body and isinstance(caller_extra_body, dict):
_translate_anthropic_response_format(
anthropic_kwargs, caller_extra_body.get("response_format"),
)
passthrough = {
k: v for k, v in caller_extra_body.items()
if k != "reasoning" and not str(k).startswith("_")
if k not in {"reasoning", "response_format"}
and not str(k).startswith("_")
}
if passthrough:
existing = anthropic_kwargs.get("extra_body") or {}
@@ -4315,6 +4374,71 @@ def _is_unsupported_temperature_error(exc: Exception) -> bool:
return _is_unsupported_parameter_error(exc, "temperature")
def _is_structured_output_rejection(exc: Exception) -> bool:
"""Detect provider 400s that reject the structured-output request field.
One predicate covers the field on both wires, because both come from the
same caller-supplied ``response_format``:
- OpenAI wire: the provider rejects ``response_format`` itself. vLLM
gateways translate the field into ``guided_grammar`` and fail when the
grammar backend is absent (``compile_grammar_error: No module named
'xgrammar'``, #82816). Other endpoints answer ``This response_format
type is unavailable now``.
- Anthropic wire: the adapter translates ``response_format`` into
``output_config.format``. Gateways that predate structured outputs
(the documented case is the ``bedrock-mantle`` Messages endpoint)
reject that field: ``output_config: Extra inputs are not permitted``.
Callers tolerate an unconstrained reply — the title prompt demands bare
JSON and ``_extract_title_text`` has a loose-JSON fallback — so the right
reaction is one retry without the field, not a hard failure.
"""
status = getattr(exc, "status_code", None)
if status is not None and status not in {400, 422}:
return False
err_lower = str(exc).lower()
# vLLM grammar-backend failures name the translated parameter, not ours.
if "guided_grammar" in err_lower or "xgrammar" in err_lower or (
"compile_grammar_error" in err_lower
):
return True
if "extra inputs are not permitted" in err_lower and (
"response_format" in err_lower or "output_config" in err_lower
):
return True
if "response_format" in err_lower and "unavailable" in err_lower:
return True
return (
_is_unsupported_parameter_error(exc, "response_format")
or _is_unsupported_parameter_error(exc, "output_config")
)
def _without_structured_output_format(kwargs: dict) -> Optional[dict]:
"""Copy *kwargs* without any ``response_format`` request field.
Removes the top-level kwarg and the ``extra_body`` entry. Returns None
when the kwargs carry no such field, so call sites do not retry a
request that the removal did not change.
"""
changed = False
retry_kwargs = dict(kwargs)
if retry_kwargs.pop("response_format", None) is not None:
changed = True
extra_body = retry_kwargs.get("extra_body")
if isinstance(extra_body, dict) and "response_format" in extra_body:
remaining = {
k: v for k, v in extra_body.items() if k != "response_format"
}
if remaining:
retry_kwargs["extra_body"] = remaining
else:
retry_kwargs.pop("extra_body", None)
changed = True
return retry_kwargs if changed else None
def _is_model_not_found_error(exc: Exception) -> bool:
"""Detect "the requested model doesn't exist" errors (404 / invalid model).
@@ -9488,6 +9612,39 @@ def _call_llm_impl(
first_err = retry_err
kwargs = retry_kwargs
if _is_structured_output_rejection(first_err):
retry_kwargs = _without_structured_output_format(kwargs)
if retry_kwargs is not None:
logger.info(
"Auxiliary %s: provider rejected the structured-output "
"format field; retrying once without it (schema "
"enforcement degrades to prompt compliance): %s",
task or "call", first_err,
)
try:
return _validate_llm_response(
_relay_sync_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# Same contract as the temperature rung: fall through to
# the max_tokens / payment / auth chains below with the
# stripped kwargs; re-raise anything those chains do not
# handle.
if not (
_is_payment_error(retry_err)
or _is_connection_error(retry_err)
or _is_auth_error(retry_err)
or "max_tokens" in str(retry_err)
or "unsupported_parameter" in str(retry_err)
):
raise
first_err = retry_err
kwargs = retry_kwargs
err_str = str(first_err)
# ZAI vision models (glm-4v-flash etc.) return error code 1210
# ("API 调用参数有误") when max_tokens is passed on multimodal
@@ -10200,6 +10357,40 @@ async def _async_call_llm_impl(
first_err = retry_err
kwargs = retry_kwargs
if _is_structured_output_rejection(first_err):
retry_kwargs = _without_structured_output_format(kwargs)
if retry_kwargs is not None:
logger.info(
"Auxiliary %s (async): provider rejected the "
"structured-output format field; retrying once without "
"it (schema enforcement degrades to prompt "
"compliance): %s",
task or "call", first_err,
)
try:
return _validate_llm_response(
await _relay_async_completion(
client,
retry_kwargs,
provider=resolved_provider,
api_mode=resolved_api_mode,
), task)
except Exception as retry_err:
# Same contract as the temperature rung: fall through to
# the max_tokens / payment / auth chains below with the
# stripped kwargs; re-raise anything those chains do not
# handle.
if not (
_is_payment_error(retry_err)
or _is_connection_error(retry_err)
or _is_auth_error(retry_err)
or "max_tokens" in str(retry_err)
or "unsupported_parameter" in str(retry_err)
):
raise
first_err = retry_err
kwargs = retry_kwargs
err_str = str(first_err)
# ZAI vision models (glm-4v-flash etc.) return error code 1210
# ("API 调用参数有误") when max_tokens is passed on multimodal
+4
View File
@@ -2652,6 +2652,10 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent.requested_provider = fb_provider
agent.base_url = fb_base_url
agent.api_mode = fb_api_mode
# Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in).
# Read from the fallback entry so the flag travels with the active
# provider; restore_primary_runtime will revert it from the snapshot.
agent._reasoning_echo_flag = bool(fb.get("reasoning_echo", False))
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
agent._fallback_activated = True
+60 -1
View File
@@ -1305,6 +1305,62 @@ def _consume_codex_event_stream(
return final
def _sanitize_consumer_codex_request(
agent: Any,
request: dict[str, Any],
) -> dict[str, Any]:
"""Drop fields the ChatGPT OAuth Codex endpoint does not accept.
This guard intentionally lives at the final wire boundary, after Relay or
other request middleware has had a chance to transform the request. The
normal transport builder already omits ``prompt_cache_retention`` for this
endpoint, but a late mutation must not be allowed to turn a valid tool
follow-up into a non-retryable HTTP 400.
Explicit ``request_overrides`` are subject to the same endpoint contract:
unsupported retention is dropped with a warning instead of being sent and
rejected by the provider. The check covers both the top-level kwarg and a
nested ``extra_body`` entry — the OpenAI SDK merges ``extra_body`` into
the outgoing JSON body, so either shape reaches the endpoint.
"""
sanitized = dict(request)
# Resolved defensively on purpose: run_codex_stream is also driven with
# lightweight stand-in agents that carry only the attributes a given path
# needs (see tests/agent/test_codex_request_transport_diagnostics.py), so a
# bare agent._is_codex_backend() here would raise AttributeError on them.
backend_predicate = getattr(agent, "_is_codex_backend", None)
is_consumer_codex = (
bool(backend_predicate()) if callable(backend_predicate) else False
)
if not is_consumer_codex:
return sanitized
dropped_from: list[str] = []
if "prompt_cache_retention" in sanitized:
sanitized.pop("prompt_cache_retention")
dropped_from.append("top-level")
# The OpenAI SDK merges ``extra_body`` into the outgoing JSON body, so a
# nested ``extra_body.prompt_cache_retention`` reaches the endpoint just
# like the top-level field would. Copy before editing — the caller's
# mapping must not be mutated — and drop the mapping when it empties.
extra_body = sanitized.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_retention" in extra_body:
extra_body = dict(extra_body)
extra_body.pop("prompt_cache_retention")
if extra_body:
sanitized["extra_body"] = extra_body
else:
sanitized.pop("extra_body")
dropped_from.append("extra_body")
if dropped_from:
logger.warning(
"Dropped unsupported prompt_cache_retention at consumer Codex "
"wire boundary (model=%s, via %s).",
sanitized.get("model", getattr(agent, "model", "unknown")),
", ".join(dropped_from),
)
return sanitized
def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta=None):
"""Execute one streaming Responses API request and return the final response.
@@ -1347,7 +1403,10 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
writer_token = {"value": None}
def _open_codex_stream(next_api_kwargs: dict[str, Any]):
stream_kwargs = dict(next_api_kwargs)
stream_kwargs = _sanitize_consumer_codex_request(
agent,
next_api_kwargs,
)
stream_kwargs["stream"] = True
return active_client.responses.create(**stream_kwargs)
+3
View File
@@ -1885,6 +1885,7 @@ class ContextCompressor(ContextEngine):
self._cooldown_persist_failed = False
self._last_summary_error = None
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self.last_real_prompt_tokens = 0
self.last_compression_rough_tokens = 0
self.last_rough_tokens_when_real_prompt_fit = 0
@@ -2167,6 +2168,7 @@ class ContextCompressor(ContextEngine):
self._summary_failure_cooldown_until = 0.0
self._cooldown_persist_failed = False
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self._context_probed = False
self._context_probe_persistable = False
self.last_real_prompt_tokens = 0
@@ -6927,6 +6929,7 @@ This compaction should PRIORITISE preserving all information related to the focu
self._last_aux_model_failure_error = None
self._last_aux_model_failure_model = None
self._last_compress_aborted = False
self._last_compress_refused_would_grow = False
self._last_compression_made_progress = False
# NOTE: do NOT reset _last_summary_auth_failure or
# _last_summary_network_failure here. These flags are set by
+11
View File
@@ -3395,6 +3395,17 @@ def compress_context(
f"{_rough_in:,}",
f"{_rough_out:,}",
)
# Flag the refusal on the compressor state so manual
# /compress feedback can report it honestly. Without this,
# the CLI compared the returned list against its pre-call
# snapshot, saw a difference (durable-snapshot adoption can
# legitimately change the count), and printed
# "✅ Compressed: 8 → 14 messages" directly under the
# refusal warning (Aug 2026 full-surface CLI QA sweep).
try:
agent.context_compressor._last_compress_refused_would_grow = True
except Exception:
pass
try:
agent._emit_warning(
"⚠️ Compression refused: the generated summary "
+126 -2
View File
@@ -107,6 +107,64 @@ logger = logging.getLogger(__name__)
_INTERRUPT_SCAFFOLD_MARKER = "[This response was interrupted by a user correction.]"
# One-time wrap-up notice appended when a wall-clock run budget crosses its
# 80% threshold (agent.run_budget_seconds / --run-budget). Mirrors the Codex
# CLI budget wrap-up template: stop new work, deliver from current state.
RUN_BUDGET_WRAPUP_NOTICE = (
"[SYSTEM NOTICE — run time budget nearly exhausted] "
"Run time budget nearly exhausted. Stop new discovery/verification work "
"now. Produce the required final deliverable (answer/JSON/summary) from "
"the state you already have, completing only mandatory writes."
)
def _maybe_inject_run_budget_wrapup(agent: Any, messages: List[Dict[str, Any]]) -> bool:
"""Inject the one-time wall-clock wrap-up notice when past 80% of budget.
Cache-safe delivery: the notice is appended to the NEWEST ``role:"tool"``
message (the same channel /steer uses) — no synthetic user message is
inserted mid-loop and no past context is rewritten, so role alternation
and the prompt-cache prefix survive. Latches ``_run_budget_wrapup_injected``
only on a successful append, so a first iteration without tool results
retries on the next iteration. Returns True when the notice was injected.
Dormant unless ``agent.run_budget_seconds`` is set AND the turn stamped
``_run_budget_started_at`` (see ``turn_context.prepare_conversation_turn``).
"""
budget = getattr(agent, "run_budget_seconds", None)
if not budget:
return False
if getattr(agent, "_run_budget_wrapup_injected", False):
return False
started = getattr(agent, "_run_budget_started_at", None)
if not started:
return False
if (time.time() - started) < 0.8 * float(budget):
return False
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if isinstance(msg, dict) and msg.get("role") == "tool":
existing = msg.get("content", "")
if isinstance(existing, str):
msg["content"] = existing + f"\n\n{RUN_BUDGET_WRAPUP_NOTICE}"
else:
# Multimodal content blocks — append a text block.
try:
blocks = list(existing) if existing else []
blocks.append({"type": "text", "text": RUN_BUDGET_WRAPUP_NOTICE})
msg["content"] = blocks
except Exception:
return False
agent._run_budget_wrapup_injected = True
logger.info(
"Run budget wrap-up notice injected (budget=%.0fs, elapsed=%.0fs)",
float(budget),
time.time() - started,
)
return True
return False
def _restore_user_after_reference_handoff(
messages: List[Dict[str, Any]], user_message: Any
) -> bool:
@@ -331,6 +389,19 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
}
if not visible:
placeholder["display_kind"] = "hidden"
# Keep the transcript hidden and empty, but give the historical
# API projection a non-empty neutral assistant turn so the
# pre-call sanitizer (repair_empty_non_final_messages) does not
# re-heal this row on every later call (#88955). display_kind is
# stripped before sanitization, while api_content is projected
# back into content for historical assistant rows. Use the
# canonical neutral interruption placeholder, never
# _INTERRUPT_SCAFFOLD_MARKER: replaying the scaffold as assistant
# text made the model echo it and self-replicate ghost rows
# (#81841).
from agent.agent_runtime_helpers import _INTERRUPTED_PLACEHOLDER
placeholder["api_content"] = _INTERRUPTED_PLACEHOLDER
append_message(messages, placeholder)
append_message(
messages,
@@ -2028,6 +2099,15 @@ def run_conversation(
existing = getattr(agent, "_pending_steer", None)
agent._pending_steer = (existing + "\n" + _pre_api_steer) if existing else _pre_api_steer
# ── Wall-clock run-budget wrap-up notice ───────────────────────
# One-shot: when a run budget (agent.run_budget_seconds /
# --run-budget) is active and 80% of it has elapsed, ask the model
# to wrap up and deliver from the state it already has. Same
# cache-safe channel as /steer (appended to the newest tool
# result); dormant when no budget is set.
if getattr(agent, "run_budget_seconds", None):
_maybe_inject_run_budget_wrapup(agent, messages)
# Prepare messages for API call
# If we have an ephemeral system prompt, prepend it to the messages
# Note: Reasoning is embedded in content via <think> tags for trajectory storage.
@@ -2123,9 +2203,29 @@ def run_conversation(
# from every outgoing copy so strict OpenAI-compatible backends
# don't reject the request after a model switch or resumed typed
# event row enters the live history.
api_msg.pop("display_kind", None)
_display_kind = api_msg.pop("display_kind", None)
api_msg.pop("display_metadata", None)
# Legacy hidden redirect placeholders (#88955): rows persisted
# BEFORE the writer-side api_content stamp in
# _apply_active_turn_redirect are content="" with no sidecar.
# Once display_kind is stripped the pre-call sanitizer
# (repair_empty_non_final_messages) would re-heal such a row on
# every call forever, since the durable transcript is never
# mutated. Give the wire copy the same neutral payload here so
# old sessions converge too. Never the interrupt scaffold —
# replaying scaffold bytes as assistant text is #81841.
if (
_display_kind == "hidden"
and api_msg.get("role") == "assistant"
and not _api_content
and not (api_msg.get("content") or "").strip()
and not api_msg.get("tool_calls")
):
from agent.agent_runtime_helpers import _INTERRUPTED_PLACEHOLDER
api_msg["content"] = _INTERRUPTED_PLACEHOLDER
# Durable row identity stamped by _rows_to_conversation so the
# desktop can address a specific persisted message (reactions).
# Bookkeeping, never a provider field — only the chat-completions
@@ -7831,10 +7931,28 @@ def run_conversation(
from agent.agent_runtime_helpers import (
intent_ack_continuation_mode,
trailing_continue_intent,
)
_ack_mode = intent_ack_continuation_mode(agent)
if (
# Said-continue-but-stopped guard (agent.stall_guards): the
# model ended the turn with no tool calls but its short reply
# TAILS with an announced next action ("Let me now…",
# "I will now…"). Unlike the intent-ack detector below, this
# fires mid-task too (after tool results), which is exactly
# where eval traces show the stall. It reuses the SAME bounded
# continuation path and counter (max 2 per turn), so the
# alternation-safe interim-assistant + user-nudge mechanism —
# not a new parallel one — carries the recovery.
_stall_continue_intent = (
bool(getattr(agent, "_stall_guards", True))
and agent.valid_tool_names
and codex_ack_continuations < 2
and trailing_continue_intent(
agent._strip_think_blocks(final_response or "")
)
)
if _stall_continue_intent or (
_ack_mode != "off"
and agent.valid_tool_names
and codex_ack_continuations < 2
@@ -7845,6 +7963,12 @@ def run_conversation(
require_workspace=(_ack_mode == "codex_only"),
)
):
if _stall_continue_intent:
logger.info(
"Stall guard: turn ending on trailing continue-"
"intent with no tool calls — re-prompting to act "
"(%d/2)", codex_ack_continuations + 1,
)
codex_ack_continuations += 1
interim_msg = agent._build_assistant_message(assistant_message, "incomplete")
append_message(messages, interim_msg)
+12
View File
@@ -139,6 +139,18 @@ def get_active_provider() -> Optional[ImageGenProvider]:
except Exception as exc:
logger.debug("Could not read image_gen.provider from config: %s", exc)
# The managed "Nous Subscription" selection is serviced by the FAL
# plugin through the managed fal-queue gateway (the legacy FAL pipeline
# routes managed when the stored selection is "nous").
if configured:
try:
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured.lower() == NOUS_MANAGED_PROVIDER:
configured = "fal"
except Exception: # pragma: no cover — helpers are in-repo
pass
with _lock:
snapshot = dict(_providers)
snapshot.update(_scoped_providers.get(hermes_home_key(), {}))
+20 -2
View File
@@ -53,6 +53,11 @@ def summarize_manual_compression(
compression_state is not None
and getattr(compression_state, "_last_compress_aborted", False) is True
)
refused_would_grow = (
compression_state is not None
and getattr(compression_state, "_last_compress_refused_would_grow", False)
is True
)
fallback_used = (
compression_state is not None
and getattr(compression_state, "_last_summary_fallback_used", False) is True
@@ -65,7 +70,12 @@ def summarize_manual_compression(
if not isinstance(failure_reason, str) or not failure_reason.strip():
failure_reason = None
if aborted:
if refused_would_grow:
headline = (
f"Compression refused (summary would grow the conversation): "
f"{before_count} messages preserved"
)
elif aborted:
headline = f"Compression aborted: {before_count} messages preserved"
elif fallback_used:
headline = (
@@ -78,6 +88,8 @@ def summarize_manual_compression(
if noop and after_tokens == before_tokens:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
elif refused_would_grow:
token_line = f"Approx request size: ~{before_tokens:,} tokens (unchanged)"
else:
token_line = (
f"Approx request size: ~{before_tokens:,} → "
@@ -85,7 +97,12 @@ def summarize_manual_compression(
)
note = None
if aborted:
if refused_would_grow:
note = (
"The generated summary was larger than what it would replace; "
"no messages were removed."
)
elif aborted:
note = "Summary generation failed; no messages were removed."
elif fallback_used:
dropped_count = getattr(
@@ -113,6 +130,7 @@ def summarize_manual_compression(
return {
"noop": noop,
"aborted": aborted,
"refused_would_grow": refused_would_grow,
"fallback_used": fallback_used,
"headline": headline,
"token_line": token_line,
+52 -2
View File
@@ -358,6 +358,25 @@ TOOL_USE_ENFORCEMENT_GUIDANCE = (
# Add new patterns here when a model family needs explicit steering.
TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek")
# Model name substrings whose sessions receive OPENAI_MODEL_EXECUTION_GUIDANCE
# (execution discipline: tool persistence, mandatory tool use for arithmetic,
# external-write read-back, count reconciliation, literal preservation,
# verification-gated completion) when agent.execution_guidance is "auto".
#
# gpt/codex/grok are the historical set; deepseek/kimi/qwen/glm/minimax/
# mimo/mistral were added after Composio agentic-eval traces showed the same
# failure modes on those families (financial math in prose, no read-back after
# external writes, identifier "repair", completeness claims despite count
# mismatches). GLM's tool-calls-as-plain-text stall (#53847) and MiMo (#41874)
# are covered here too. Gemini/Gemma are excluded — they get the more specific
# GOOGLE_MODEL_OPERATIONAL_GUIDANCE block instead. Claude is excluded because
# it does not exhibit these failure modes; users can opt any model in via
# config.yaml `agent.execution_guidance: true` or a substring list.
EXECUTION_GUIDANCE_MODELS = (
"gpt", "codex", "grok",
"deepseek", "kimi", "qwen", "glm", "minimax", "mimo", "mistral",
)
# Universal "finish the job" guidance — applied to ALL models, not gated
# by model family. Addresses two cross-model failure modes:
# 1. Stopping after a stub: writing a tiny file or running one command
@@ -438,13 +457,22 @@ PARALLEL_TOOL_CALL_GUIDANCE = (
# without tool calls, suggests workarounds instead of using existing tools,
# replies with plans/suggestions instead of executing). The body is
# family-agnostic; the OPENAI_ prefix reflects origin, not exclusivity.
#
# As of the Composio agentic-eval follow-up, the block is no longer fenced to
# gpt/codex/grok: eval traces showed DeepSeek/Kimi doing financial math in
# prose, skipping read-back verification after external writes, "repairing"
# malformed identifiers, and claiming completeness despite count mismatches —
# exactly the failure modes this block targets. The injection gate lives in
# agent/system_prompt.py and is controlled by config.yaml
# ``agent.execution_guidance`` (auto/true/false/list); "auto" matches the
# EXECUTION_GUIDANCE_MODELS substring tuple below.
OPENAI_MODEL_EXECUTION_GUIDANCE = (
"# Execution discipline\n"
"<tool_persistence>\n"
"- Use tools whenever they improve correctness, completeness, or grounding.\n"
"- Do not stop early when another tool call would materially improve the result.\n"
"- If a tool returns empty or partial results, retry with a different query or "
"strategy before giving up.\n"
"- If a tool returns empty, partial, or suspiciously narrow results, retry "
"with a broader or different query or strategy before concluding.\n"
"- Keep calling tools until: (1) the task is complete, AND (2) you have verified "
"the result.\n"
"</tool_persistence>\n"
@@ -487,8 +515,30 @@ OPENAI_MODEL_EXECUTION_GUIDANCE = (
"- Formatting: does the output match the requested format or schema?\n"
"- Safety: if the next step has side effects (file writes, commands, API calls), "
"confirm scope before executing.\n"
"- Completion: 'done' means every named acceptance criterion is verified — "
"never a plausible subset. Completing your plan is not itself the answer; "
"the requested output must appear in your response.\n"
"</verification>\n"
"\n"
"<external_state_verification>\n"
"- After any state-changing write to an external system (API call, message "
"post, record update), verify the effect by reading back the exact target "
"before claiming success — a successful tool call is not a successful task. "
"Do NOT re-verify internal file edits a tool already confirmed.\n"
"- Declared totals in responses (total, reply_count, has_more, '...N more') "
"are hard assertions. If your enumerated count disagrees, re-fetch or parse "
"programmatically — never finalize on 'go with what I have'.\n"
"- When building write payloads, set fields explicitly rather than relying "
"on provider defaults that could contradict intent.\n"
"</external_state_verification>\n"
"\n"
"<literal_preservation>\n"
"- Preserve identifiers, commands, and values exactly as given — never "
"'repair' or normalize a token that fails a stated format. A successful "
"lookup does not validate a malformed source token; validate format first, "
"then look up.\n"
"</literal_preservation>\n"
"\n"
"<missing_context>\n"
"- If required context is missing, do NOT guess or hallucinate an answer.\n"
"- Use the appropriate lookup tool when missing information is retrievable "
+204
View File
@@ -0,0 +1,204 @@
"""Canonical reasoning-effort vocabulary and wire clamping.
Hermes' internal effort ladder (``hermes_constants.VALID_REASONING_EFFORTS``
plus the ``none`` disable level) is wider than what any single provider wire
accepts. Historically every transport and provider profile hand-rolled its own
translation map, and the class of bugs that produced was constant: a new
internal level (``ultra``) leaking to a wire that rejects it with HTTP 400
(#89503, #70058), or an unknown level being dropped to a weak default so the
strongest ask resolved *weaker* than an explicit ``high`` — a ladder
inversion (#74295, #87279).
This module is the single source of truth both kinds of code use instead:
- :data:`EFFORT_LADDER` — canonical low→high ordering.
- :func:`clamp_effort` — the one clamping policy: keep a supported level
verbatim, otherwise take the **nearest weaker** supported level (never
silently escalate cost above what was asked), and only when nothing weaker
exists take the weakest supported level (a provider whose minimum thinking
level is ``high`` serves ``high`` for a ``low`` ask — GLM-5.2's shape).
- Named wire-vocabulary constants for the common OpenAI-compatible surfaces,
so call sites declare *data* ("this route accepts these levels") rather
than logic.
Rules for call sites:
1. **Wire shape stays local.** Whether a route wants ``extra_body.reasoning``,
a top-level ``reasoning_effort`` string, or a ``thinking`` toggle is the
caller's business. Only the *vocabulary math* lives here.
2. **Unset stays unset.** ``clamp_effort`` translates an explicit request; it
does not invent one. When the user expressed no effort, prefer omitting
the field so the server default applies.
3. **Never patch a predicate.** When a provider rejects a level, fix its
declared supported set (data), never add another vendor-name special case
at the call site.
"""
from __future__ import annotations
from typing import Optional, Sequence
# Canonical low→high ordering used for nearest-level clamping. Superset of
# hermes_constants.VALID_REASONING_EFFORTS ("none" included so an explicit
# disable can be clamped too when a provider publishes it as a level).
EFFORT_LADDER: tuple[str, ...] = (
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
)
# ``ultra`` is Hermes-internal ladder vocabulary (the Codex product tier); no
# provider wire accepts it verbatim anywhere. Every declared wire set below
# therefore stops at ``max`` — ``ultra`` always clamps down.
#: The widest OpenAI-compatible wire vocabulary (OpenRouter, Nous Portal):
#: exactly max|xhigh|high|medium|low|minimal|none.
OPENAI_COMPAT_WIRE_EFFORTS: tuple[str, ...] = (
"none", "minimal", "low", "medium", "high", "xhigh", "max",
)
#: OpenAI/Codex Responses backend — per-model vocabulary, live-verified
#: (Aug 2026): ``minimal`` is rejected by both generations (clamps to low);
#: ``max`` is gpt-5.6-only — gpt-5.5 rejects it with "Supported values are:
#: 'none', 'low', 'medium', 'high', 'xhigh'" (#68365's premise, confirmed).
CODEX_GPT56_EFFORTS: tuple[str, ...] = (
"none", "low", "medium", "high", "xhigh", "max",
)
CODEX_LEGACY_EFFORTS: tuple[str, ...] = (
"none", "low", "medium", "high", "xhigh",
)
def codex_supported_efforts(model: Optional[str]) -> tuple[str, ...]:
"""Supported effort set for an OpenAI/Codex Responses model."""
if "gpt-5.6" in (model or "").lower():
return CODEX_GPT56_EFFORTS
return CODEX_LEGACY_EFFORTS
#: Backward-compat alias (pre-#68365-verification name).
CODEX_RESPONSES_EFFORTS: tuple[str, ...] = CODEX_GPT56_EFFORTS
#: xAI Responses — Grok 4.6+ accepts xhigh; older Grok tops out at high.
XAI_GROK46_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "xhigh")
XAI_LEGACY_EFFORTS: tuple[str, ...] = ("low", "medium", "high")
#: Actual Computer relays (SGLang/vLLM): none/low/medium/high/max.
ACTUAL_RELAY_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max")
#: Moonshot/Kimi K3: low/high/max (server default high).
KIMI_K3_EFFORTS: tuple[str, ...] = ("low", "high", "max")
#: Moonshot/Kimi K2-era models: low/medium/high.
KIMI_K2_EFFORTS: tuple[str, ...] = ("low", "medium", "high")
#: Tencent TokenHub: low/medium/high.
TOKENHUB_EFFORTS: tuple[str, ...] = ("low", "medium", "high")
#: Kimi K3's vendor-documented translation quirks (platform.kimi.ai
#: thinking-model guide): ``high`` is K3's positional middle AND server
#: default, so ``medium`` rounds to it rather than down to ``low``; ``xhigh``
#: rounds up to ``max`` (K3's top tier), matching the kimi-coding plugin.
KIMI_K3_OVERRIDES: dict[str, str] = {"medium": "high", "xhigh": "max"}
#: GLM-5.2 native reasoning_effort knob: exactly two enabled levels,
#: ``high`` (its minimum thinking level) and ``max`` (per Z.AI/BigModel
#: docs). ``xhigh`` requests the top tier, not the floor.
GLM52_EFFORTS: tuple[str, ...] = ("high", "max")
GLM52_OVERRIDES: dict[str, str] = {"xhigh": "max"}
#: DeepSeek V4 OpenAI-compat endpoint: low/medium/high/max; ``xhigh``
#: requests the top tier (matches the shipped profile mapping).
DEEPSEEK_V4_EFFORTS: tuple[str, ...] = ("low", "medium", "high", "max")
DEEPSEEK_V4_OVERRIDES: dict[str, str] = {"xhigh": "max"}
#: Ollama Cloud /v1/chat/completions: accepts {none, low, medium, high, max};
#: rejects ``minimal`` with HTTP 400. ``xhigh`` requests the top tier.
OLLAMA_CLOUD_EFFORTS: tuple[str, ...] = ("none", "low", "medium", "high", "max")
OLLAMA_CLOUD_OVERRIDES: dict[str, str] = {"xhigh": "max"}
#: Meta Model API (Muse): minimal..xhigh; rejects ``none``.
META_AI_EFFORTS: tuple[str, ...] = ("minimal", "low", "medium", "high", "xhigh")
#: Upstage Solar Pro/Open: low/medium/high.
SOLAR_EFFORTS: tuple[str, ...] = ("low", "medium", "high")
def kimi_supported_efforts(model: Optional[str]) -> tuple[str, ...]:
"""Supported effort set for a Moonshot/Kimi model slug.
K3 is served as the bare slug ``k3`` and the ``kimi-k3*`` aliases; its
documented set is low/high/max. Everything earlier speaks low/medium/high.
"""
m = (model or "").strip().lower().split("/")[-1]
if m == "k3" or m.startswith("kimi-k3"):
return KIMI_K3_EFFORTS
return KIMI_K2_EFFORTS
def clamp_effort(
effort: Optional[str],
supported: Optional[Sequence[str]],
overrides: Optional[dict[str, str]] = None,
) -> Optional[str]:
"""Clamp a requested reasoning effort onto a wire's supported levels.
``overrides`` is an optional declared mapping consulted first, for routes
whose vendor documents a translation that differs from nearest-weaker
(Kimi K3 documents ``medium → high``: high is its positional middle and
server default). Overrides are data, not logic — a call site never adds
vendor ``if``\\ s around this function.
Otherwise: returns the requested effort unchanged when it is supported,
when the supported set is unknown (``None``/empty), or when the effort
isn't a recognized ladder level (custom providers may use bespoke names —
pass through rather than guess). Otherwise returns the **nearest weaker**
supported level, so a clamp never silently escalates cost; when nothing
weaker exists, the weakest supported level is returned (the caller asked
for *some* thinking and the provider's floor is the closest honest match).
The policy is monotonic: a stronger request never resolves to a weaker
wire level than a weaker request would.
"""
requested = str(effort or "").strip().lower()
if not requested or not supported:
return effort
supported_norm = [
str(level).strip().lower()
for level in supported
if str(level).strip().lower() in EFFORT_LADDER
]
if not supported_norm or requested in supported_norm:
return effort
if overrides:
mapped = overrides.get(requested)
if mapped in supported_norm:
return mapped
if requested not in EFFORT_LADDER:
return effort
# "none" disables reasoning — it is never a *degradation target* for an
# enabled ask (clamping "minimal" to "none" would silently switch
# thinking off). It still passes through verbatim when requested.
candidates = [level for level in supported_norm if level != "none"]
if not candidates:
return effort
requested_idx = EFFORT_LADDER.index(requested)
below = [
level for level in candidates
if EFFORT_LADDER.index(level) < requested_idx
]
if below:
return max(below, key=EFFORT_LADDER.index)
return min(candidates, key=EFFORT_LADDER.index)
def requested_effort(reasoning_config: Optional[dict]) -> Optional[str]:
"""Extract the user's explicit effort from a reasoning config, or None.
Returns ``None`` when the config is absent, malformed, carries no effort,
or reasoning is explicitly disabled — callers should then omit the wire
field entirely so the server default applies (rule 2 above).
"""
if not isinstance(reasoning_config, dict):
return None
if reasoning_config.get("enabled") is False:
return None
effort = str(reasoning_config.get("effort") or "").strip().lower()
return effort or None
+100 -70
View File
@@ -434,6 +434,7 @@ class ManagedLlmStream(Iterator[Any]):
self._stream: Any = None
self._raw_stream_resource: Any = None
self._closed = False
self._runtime_lease: relay_runtime.RelayOperationLease | None = None
self._close_error: BaseException | None = None
self._callback_error: BaseException | None = None
self._logical: tuple[relay_runtime.RelayTurnContext, Any, str] | None = None
@@ -573,7 +574,12 @@ class ManagedLlmStream(Iterator[Any]):
self._callback_error = exc
raise
loop = asyncio.new_event_loop()
self._runtime_lease = runtime.acquire_operation_lease()
try:
loop = asyncio.new_event_loop()
except BaseException:
self._release_runtime_lease()
raise
self._loop = loop
self._relay_observes_chunks = True
try:
@@ -613,10 +619,14 @@ class ManagedLlmStream(Iterator[Any]):
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
operation_lease=self._runtime_lease,
)
self._logical = None
loop.close()
self._loop = None
try:
loop.close()
finally:
self._loop = None
self._release_runtime_lease()
raise
def __iter__(self) -> "ManagedLlmStream":
@@ -662,6 +672,7 @@ class ManagedLlmStream(Iterator[Any]):
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
operation_lease=self._runtime_lease,
)
self._logical = None
self._close(logical_outcome="cancelled")
@@ -719,8 +730,75 @@ class ManagedLlmStream(Iterator[Any]):
self._stream = iter(pending)
self._raw_stream_resource = None
self._accept_chunk = None
if loop is not None:
close = getattr(relay_stream, "aclose", None)
try:
if loop is not None:
close = getattr(relay_stream, "aclose", None)
if callable(close):
async def close_stream() -> None:
await close()
try:
loop.run_until_complete(close_stream())
except Exception:
logger.debug(
"Relay stream cleanup failed during provider fallback",
exc_info=True,
)
loop.close()
if not self._defer_logical_completion:
_complete_logical(
self._logical,
outcome="success",
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
operation_lease=self._runtime_lease,
)
self._logical = None
finally:
self._release_runtime_lease()
def _close(self, *, logical_outcome: str) -> None:
if self._closed:
return
self._closed = True
self._prefetched_chunks.clear()
try:
loop = self._loop
self._loop = None
if loop is None:
resources = (self._stream, self._raw_stream_resource)
self._stream = None
self._raw_stream_resource = None
closed_ids: set[int] = set()
for resource in resources:
if resource is None or id(resource) in closed_ids:
continue
closed_ids.add(id(resource))
close = getattr(resource, "close", None)
if callable(close):
try:
close()
except Exception as exc:
if self._close_error is None:
self._close_error = exc
logger.debug(
"Provider stream cleanup failed",
exc_info=True,
)
if not self._defer_logical_completion:
_complete_logical(
self._logical,
outcome=logical_outcome,
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
operation_lease=self._runtime_lease,
)
self._logical = None
return
close = getattr(self._stream, "aclose", None)
if callable(close):
async def close_stream() -> None:
@@ -728,49 +806,9 @@ class ManagedLlmStream(Iterator[Any]):
try:
loop.run_until_complete(close_stream())
except Exception:
logger.debug(
"Relay stream cleanup failed during provider fallback",
exc_info=True,
)
loop.close()
if not self._defer_logical_completion:
_complete_logical(
self._logical,
outcome="success",
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
)
self._logical = None
def _close(self, *, logical_outcome: str) -> None:
if self._closed:
return
self._closed = True
self._prefetched_chunks.clear()
loop = self._loop
self._loop = None
if loop is None:
resources = (self._stream, self._raw_stream_resource)
self._stream = None
self._raw_stream_resource = None
closed_ids: set[int] = set()
for resource in resources:
if resource is None or id(resource) in closed_ids:
continue
closed_ids.add(id(resource))
close = getattr(resource, "close", None)
if callable(close):
try:
close()
except Exception as exc:
if self._close_error is None:
self._close_error = exc
logger.debug(
"Provider stream cleanup failed",
exc_info=True,
)
except Exception as exc:
if self._close_error is None:
self._close_error = exc
if not self._defer_logical_completion:
_complete_logical(
self._logical,
@@ -778,30 +816,18 @@ class ManagedLlmStream(Iterator[Any]):
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
operation_lease=self._runtime_lease,
)
self._logical = None
return
close = getattr(self._stream, "aclose", None)
if callable(close):
loop.close()
finally:
self._release_runtime_lease()
async def close_stream() -> None:
await close()
try:
loop.run_until_complete(close_stream())
except Exception as exc:
if self._close_error is None:
self._close_error = exc
if not self._defer_logical_completion:
_complete_logical(
self._logical,
outcome=logical_outcome,
model_name=self._logical_model_name,
provider_name=self._logical_provider_name,
response_model_name=self._logical_response_model_name,
)
self._logical = None
loop.close()
def _release_runtime_lease(self) -> None:
lease = self._runtime_lease
self._runtime_lease = None
if lease is not None:
lease.release()
def __del__(self) -> None:
self._close(logical_outcome="cancelled")
@@ -941,6 +967,7 @@ def _complete_logical(
model_name: str | None = None,
provider_name: str | None = None,
response_model_name: str | None = None,
operation_lease: relay_runtime.RelayOperationLease | None = None,
) -> None:
if logical is None:
return
@@ -960,7 +987,10 @@ def _complete_logical(
output.update({"model": model_name, "provider": provider_name})
if response_model_name is not None:
output["response_model"] = response_model_name
lease.host.run_in_session(
callback = lease.host.run_in_session
if operation_lease is not None:
callback = operation_lease.run_in_session
callback(
lease.session,
relay_runtime.pop_relay_scope,
lease.host.relay,
+492 -41
View File
@@ -8,13 +8,21 @@ import contextvars
import importlib
import inspect
import logging
import os
import threading
import tomllib
import uuid
from concurrent.futures import TimeoutError as FuturesTimeoutError
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Any, Callable
from hermes_constants import get_hermes_home
from hermes_cli.relay_plugin_cutover import (
RELAY_PLUGINS_CONFIG_ENV,
configured_legacy_relay_env_vars,
)
logger = logging.getLogger(__name__)
@@ -24,6 +32,7 @@ LOGICAL_LLM_SCOPE = "hermes.logical_llm_call"
RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version"
RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1"
RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance"
RELAY_PLUGINS_EXECUTION_CONSUMER = "hermes.nemo_relay.plugins"
_PROFILE_KEY_CACHE: dict[str, str] = {}
# Bound for native scope lifecycle operations (push/pop/flush) that gate
@@ -128,6 +137,20 @@ def pop_relay_scope(
return pop(handle, **kwargs)
class _RelayPluginConfigurationState(Enum):
"""Process-wide result shared by every currently hosted profile."""
UNINITIALIZED = auto()
DISABLED = auto()
ACTIVE = auto()
FOREIGN = auto()
FAILED = auto()
class _RelayPluginConfigurationLoadError(RuntimeError):
"""An explicitly selected Relay plugin configuration could not be loaded."""
@dataclass
class RelaySession:
"""One isolated Relay scope stack owned by a Hermes session."""
@@ -199,8 +222,232 @@ def _reset_segments_config_for_tests() -> None:
_SEGMENTS_CONFIG = None
class RelayOperationLease:
"""Keep process-wide Relay plugins alive across a deferred operation."""
def __init__(self, runtime: "RelayRuntime") -> None:
self._lock = threading.Lock()
self._runtime: RelayRuntime | None = runtime
def run_in_session(
self,
session: RelaySession,
callback: Callable[..., Any],
*args: Any,
**kwargs: Any,
) -> Any:
"""Run cleanup while this lease still owns the runtime lifetime."""
with self._lock:
runtime = self._runtime
if runtime is None:
raise RuntimeError("Hermes Relay operation lease is released")
return runtime._run_in_session_untracked(
session,
callback,
*args,
**kwargs,
)
def release(self) -> None:
"""Release this lease exactly once."""
with self._lock:
runtime = self._runtime
self._runtime = None
if runtime is not None:
runtime._end_operation()
class _ProcessRelayPluginConfiguration:
"""Own one Relay plugin configuration across profile-scoped hosts."""
def __init__(self) -> None:
self._lock = threading.RLock()
self._owners: set[int] = set()
self._state = _RelayPluginConfigurationState.UNINITIALIZED
self._active = False
self._relay: Any = None
self._activation: Any = None
def acquire(
self,
owner: Any,
relay: Any,
) -> _RelayPluginConfigurationState:
"""Join the process configuration, initializing it for the first host."""
owner_id = id(owner)
with self._lock:
if owner_id in self._owners:
return self._state
if self._owners:
self._owners.add(owner_id)
return self._state
if self._active and not self._clear_active():
logger.warning(
"Hermes Relay plugin cleanup is still pending; refusing to "
"replace the process-global configuration"
)
return self._remember(
owner_id,
_RelayPluginConfigurationState.FAILED,
)
try:
existing_report = relay.plugin.report()
except Exception:
logger.warning(
"Hermes could not determine whether a process-global Relay "
"plugin configuration is already active; refusing to replace it",
exc_info=True,
)
return self._remember(
owner_id,
_RelayPluginConfigurationState.FAILED,
)
if existing_report is not None:
logger.warning(
"A process-global Relay plugin configuration is already active "
"outside Hermes native ownership; leaving it unchanged and "
"disabling Hermes-managed Relay middleware for this process"
)
return self._remember(
owner_id,
_RelayPluginConfigurationState.FOREIGN,
)
try:
configured_inputs = _configured_plugin_inputs(relay)
if configured_inputs is None:
return self._remember(
owner_id,
_RelayPluginConfigurationState.DISABLED,
)
plugin_config, dynamic_plugins = configured_inputs
if dynamic_plugins:
try:
activation = _resolve_plugin_awaitable(
relay.plugin.initialize_with_dynamic_plugins(
plugin_config,
dynamic_plugins,
)
)
if activation is None:
raise RuntimeError(
"NeMo Relay dynamic plugin initialization "
"returned no activation handle"
)
self._activation = activation
except Exception as exc:
raise RuntimeError(
"Hermes Relay dynamic plugin activation failed"
) from exc
if self._activation is None:
# Hermes only enters Relay's initialization path after an
# explicit opt-in. Relay currently owns any subsequent ambient
# layering; a future discovery=False API can make this exact.
_resolve_plugin_awaitable(relay.plugin.initialize(plugin_config))
except Exception as exc:
self._activation = None
logger.warning(
"Hermes Relay plugin initialization failed: %s",
exc,
exc_info=True,
)
return self._remember(
owner_id,
_RelayPluginConfigurationState.FAILED,
)
self._active = True
self._relay = relay
state = self._remember(
owner_id,
_RelayPluginConfigurationState.ACTIVE,
)
logger.info(
"Relay plugins are active process-wide and apply to all profiles "
"hosted by this Hermes process."
)
return state
def _remember(
self,
owner_id: int,
state: _RelayPluginConfigurationState,
) -> _RelayPluginConfigurationState:
"""Retain one process decision for all concurrently hosted profiles."""
self._owners.add(owner_id)
self._state = state
return state
def release(self, owner: Any) -> None:
"""Release one host and clear Relay after the final host exits."""
owner_id = id(owner)
with self._lock:
if owner_id not in self._owners:
return
self._owners.remove(owner_id)
if self._owners:
return
if self._clear_active():
self._state = _RelayPluginConfigurationState.UNINITIALIZED
def reset_for_tests(self) -> None:
"""Clear process-global state left by directly constructed test hosts."""
with self._lock:
self._owners.clear()
if self._clear_active():
self._state = _RelayPluginConfigurationState.UNINITIALIZED
def retry_pending_cleanup(self) -> None:
"""Retry a failed final cleanup without disrupting live owners."""
with self._lock:
if not self._owners:
if self._clear_active():
self._state = _RelayPluginConfigurationState.UNINITIALIZED
def _clear_active(self) -> bool:
relay = self._relay
activation = self._activation
active = self._active
if not active or relay is None:
return True
try:
_flush_relay_subscribers(relay)
except Exception:
logger.warning(
"Hermes Relay plugin subscriber flush failed",
exc_info=True,
)
return False
try:
if activation is not None:
close = getattr(activation, "close", None)
if not callable(close):
raise RuntimeError(
"NeMo Relay dynamic plugin activation has no close method"
)
_resolve_plugin_awaitable(close())
else:
_clear_relay_plugins(relay)
except Exception:
logger.warning(
"Hermes Relay plugin configuration cleanup failed",
exc_info=True,
)
return False
self._active = False
self._relay = None
self._activation = None
return True
_PLUGIN_CONFIGURATION = _ProcessRelayPluginConfiguration()
atexit.register(_PLUGIN_CONFIGURATION.retry_pending_cleanup)
class RelayRuntime:
"""Own Relay session scopes independently of any exporter or plugin."""
"""Own Relay session scopes and optional process plugin configuration."""
def __init__(self, relay: Any = None, *, profile_key: str | None = None) -> None:
self.relay = relay or _load_nemo_relay()
@@ -210,8 +457,24 @@ class RelayRuntime:
self._sessions: dict[str, RelaySession] = {}
self._subagent_parents: dict[str, str] = {}
self._subagent_parent_handles: dict[str, Any] = {}
self._closing = False
self._shutdown_started = False
self._shutdown_complete = threading.Event()
self._operations_idle = threading.Event()
self._operations_idle.set()
self._active_operations = 0
self._execution_consumers_lock = threading.RLock()
self._execution_consumers: set[str] = set()
self._plugin_configuration_state = _PLUGIN_CONFIGURATION.acquire(
self,
self.relay,
)
self._plugin_configuration_registered = True
if (
self._plugin_configuration_state
is _RelayPluginConfigurationState.ACTIVE
):
self.retain_managed_execution(RELAY_PLUGINS_EXECUTION_CONSUMER)
self._shutdown_registered = True
atexit.register(self.shutdown)
@@ -244,6 +507,8 @@ class RelayRuntime:
if not session_id:
return None
with self._sessions_lock:
if self._closing:
return None
session = self._sessions.get(session_id)
if session is None:
parent_session_id = self._subagent_parents.get(session_id, "")
@@ -404,6 +669,8 @@ class RelayRuntime:
):
parent_handle = turn.handle
with self._sessions_lock:
if self._closing:
return None
self._subagent_parents[child_session_id] = parent_session_id
if parent_handle is not None:
self._subagent_parent_handles[child_session_id] = parent_handle
@@ -425,6 +692,8 @@ class RelayRuntime:
def get_session(self, session_id: str) -> RelaySession | None:
"""Return an active Hermes Relay session without creating one."""
with self._sessions_lock:
if self._closing:
return None
session = self._sessions.get(str(session_id or ""))
if session is None:
return None
@@ -458,6 +727,29 @@ class RelayRuntime:
span, never the agent. The abandoned daemon worker cannot block
process exit (tools.daemon_pool contract).
"""
self._begin_operation()
try:
return self._run_in_session_untracked(
session,
callback,
*args,
allow_closing=allow_closing,
timeout=timeout,
**kwargs,
)
finally:
self._end_operation()
def _run_in_session_untracked(
self,
session: RelaySession,
callback: Callable[..., Any],
*args: Any,
allow_closing: bool = False,
timeout: float | None = None,
**kwargs: Any,
) -> Any:
"""Run inside a session whose host-level lifetime is already held."""
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
@@ -506,26 +798,49 @@ class RelayRuntime:
**kwargs: Any,
) -> Any:
"""Create and await an operation inside the session's saved context."""
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
if session.context is None or session.handle is None:
raise RuntimeError("Hermes Relay session context is unavailable")
relay_context = session.context.copy()
self._begin_operation()
try:
with session.lock:
if session.closing and not allow_closing:
raise RuntimeError("Hermes Relay session is closing")
if session.context is None or session.handle is None:
raise RuntimeError("Hermes Relay session context is unavailable")
relay_context = session.context.copy()
context = contextvars.copy_context()
for variable, value in relay_context.items():
context.run(variable.set, value)
context = contextvars.copy_context()
for variable, value in relay_context.items():
context.run(variable.set, value)
async def invoke() -> Any:
self.relay.get_scope_stack()
result = callback(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
async def invoke() -> Any:
self.relay.get_scope_stack()
result = callback(*args, **kwargs)
if inspect.isawaitable(result):
return await result
return result
task = context.run(asyncio.create_task, invoke())
return await task
task = context.run(asyncio.create_task, invoke())
return await task
finally:
self._end_operation()
def _begin_operation(self) -> None:
"""Admit one Relay call while keeping process plugins alive."""
with self._sessions_lock:
if self._closing:
raise RuntimeError("Hermes Relay runtime is shutting down")
self._active_operations += 1
self._operations_idle.clear()
def _end_operation(self) -> None:
with self._sessions_lock:
self._active_operations -= 1
if self._active_operations == 0:
self._operations_idle.set()
def acquire_operation_lease(self) -> RelayOperationLease:
"""Retain plugin lifetime for work that outlives one Relay await."""
self._begin_operation()
return RelayOperationLease(self)
def emit_mark(
self,
@@ -586,6 +901,7 @@ class RelayRuntime:
allow_closing: bool = False,
failure_label: str = "scope close failed",
drain_limit: int = 32,
operation_already_held: bool = False,
) -> str | None:
"""Pop ``handle``, draining orphaned children in the same session context.
@@ -702,7 +1018,12 @@ class RelayRuntime:
error_holder["retry"] = retry_exc
try:
self.run_in_session(
run_in_session = (
self._run_in_session_untracked
if operation_already_held
else self.run_in_session
)
run_in_session(
session,
close_with_drain,
allow_closing=allow_closing,
@@ -721,6 +1042,17 @@ class RelayRuntime:
def close_session(self, event: dict[str, Any]) -> None:
"""Close one session scope and remove it from the core registry."""
try:
self._begin_operation()
except RuntimeError:
return
try:
self._close_session(event)
finally:
self._end_operation()
def _close_session(self, event: dict[str, Any]) -> None:
"""Close one session already admitted by the host lifecycle gate."""
session_id = _session_id(event)
with self._sessions_lock:
session = self._sessions.get(session_id)
@@ -741,23 +1073,13 @@ class RelayRuntime:
output={},
allow_closing=True,
failure_label="session scope close failed",
operation_already_held=True,
)
if failure:
failures.append(failure)
try:
try:
_scope_op_executor().submit(
self.relay.subscribers.flush
).result(timeout=_SCOPE_OP_TIMEOUT)
except RuntimeError:
# Interpreter shutdown: executor refuses new futures; flush
# on a bounded exit thread so a wedged pipeline cannot
# block process exit.
_run_bounded_on_exit_thread(
self.relay.subscribers.flush, _SCOPE_OP_TIMEOUT
)
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
# Subscriber flushing is process-wide and may wait for publications
# owned by other sessions. Final plugin teardown flushes once after all
# tracked operations drain; doing it here can deadlock an asyncio loop.
with self._sessions_lock:
if self._sessions.get(session_id) is session:
self._sessions.pop(session_id, None)
@@ -771,17 +1093,64 @@ class RelayRuntime:
)
def shutdown(self) -> None:
"""Close all core-owned Relay session scopes."""
"""Close core scopes and release process plugin configuration."""
with self._sessions_lock:
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self.close_session, {"session_id": session_id})
if self._shutdown_registered:
if self._shutdown_started:
return
self._shutdown_started = True
self._closing = True
has_active_operations = self._active_operations > 0
if has_active_operations:
thread = threading.Thread(
target=self._finish_shutdown_after_operations,
name=f"hermes-nemo-relay-shutdown-{self.runtime_id[:8]}",
daemon=True,
)
try:
atexit.unregister(self.shutdown)
thread.start()
except Exception:
pass
self._shutdown_registered = False
with self._sessions_lock:
self._shutdown_started = False
logger.warning(
"Hermes Relay deferred shutdown could not start",
exc_info=True,
)
return
self._finish_shutdown()
def _finish_shutdown_after_operations(self) -> None:
self._operations_idle.wait()
self._finish_shutdown()
def _finish_shutdown(self) -> None:
try:
with self._sessions_lock:
session_ids = list(self._sessions)
for session_id in session_ids:
self._safe(self._close_session, {"session_id": session_id})
if self._plugin_configuration_registered:
if (
self._plugin_configuration_state
is _RelayPluginConfigurationState.ACTIVE
):
self.release_managed_execution(
RELAY_PLUGINS_EXECUTION_CONSUMER
)
_PLUGIN_CONFIGURATION.release(self)
self._plugin_configuration_registered = False
if self._shutdown_registered:
try:
atexit.unregister(self.shutdown)
except Exception:
pass
self._shutdown_registered = False
except Exception:
with self._sessions_lock:
self._shutdown_started = False
logger.warning("Hermes Relay shutdown failed", exc_info=True)
return
with self._sessions_lock:
self._shutdown_complete.set()
@staticmethod
def _safe(callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
@@ -1611,6 +1980,87 @@ def _load_nemo_relay() -> Any:
return importlib.import_module("nemo_relay")
def _configured_plugin_inputs(
relay: Any,
) -> tuple[dict[str, Any], list[Any]] | None:
"""Load selected plugin inputs, or return ``None`` when none were selected."""
configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip()
if not configured:
legacy_vars = configured_legacy_relay_env_vars(os.environ)
if legacy_vars:
logger.warning(
"Legacy NeMo Relay exporter variables are set but no %s was "
"provided. %s no longer activate Relay exporters; migrate the "
"exporter configuration to a Relay plugins.toml file.",
RELAY_PLUGINS_CONFIG_ENV,
", ".join(legacy_vars),
)
return None
config_path = Path(configured).expanduser()
try:
with config_path.open("rb") as config_file:
config = tomllib.load(config_file)
if "dynamic_plugins" in config:
raise ValueError(
"Hermes [[dynamic_plugins]] records are unsupported; use Relay "
"[[plugins.dynamic]] records"
)
dynamic_plugins: list[Any] = []
if "plugins" in config:
dynamic_plugins = relay.plugin.load_dynamic_plugin_activation_specs(
config_path
)
plugin_config = dict(config)
plugin_config.pop("plugins", None)
return plugin_config, dynamic_plugins
except Exception as exc:
raise _RelayPluginConfigurationLoadError(
"Hermes Relay plugin configuration could not be loaded from "
f"{config_path}; continuing without Relay plugins"
) from exc
def _flush_relay_subscribers(relay: Any) -> None:
"""Flush Relay without blocking an asyncio event-loop thread."""
_resolve_plugin_awaitable(relay.subscribers.flush_async())
def _clear_relay_plugins(relay: Any) -> None:
"""Clear Relay plugins without blocking an asyncio event-loop thread."""
_resolve_plugin_awaitable(relay.plugin.clear_async())
def _resolve_plugin_awaitable(value: Any) -> Any:
"""Resolve Relay's async plugin API from synchronous host construction."""
if not inspect.isawaitable(value):
return value
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(value)
result: dict[str, Any] = {}
error: dict[str, BaseException] = {}
def _runner() -> None:
try:
result["value"] = asyncio.run(value)
except BaseException as exc: # pragma: no cover - re-raised below
error["exc"] = exc
thread = threading.Thread(
target=_runner,
name="hermes-nemo-relay-plugin-lifecycle",
daemon=True,
)
thread.start()
thread.join()
if "exc" in error:
raise error["exc"]
return result.get("value")
def _session_id(event: dict[str, Any]) -> str:
return str(event.get("session_id") or "")
@@ -1619,4 +2069,5 @@ def _reset_for_tests() -> None:
"""Reset all profile-scoped Relay hosts for isolated tests."""
SESSION_COORDINATOR._reset_active_turns_for_tests()
HOST_REGISTRY.shutdown_all()
_PLUGIN_CONFIGURATION.reset_for_tests()
_PROFILE_KEY_CACHE.clear()
+45 -14
View File
@@ -8,6 +8,7 @@ import json
import logging
import os
import re
import threading
from pathlib import Path
from typing import Any, Dict, Optional
@@ -24,6 +25,10 @@ logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {}
_skill_commands_platform: Optional[str] = None
_skill_commands_home: Optional[str] = None
# Guards the (map, platform-tag, home-tag) triple so publication and the
# freshness lookup always see a consistent snapshot. Scanning itself stays
# outside this lock.
_publish_lock = threading.Lock()
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
@@ -423,9 +428,15 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}.
"""
global _skill_commands, _skill_commands_platform, _skill_commands_home
_skill_commands_platform = _resolve_skill_commands_platform()
_skill_commands_home = _resolve_skill_commands_home()
_skill_commands = {}
platform = _resolve_skill_commands_platform()
home = _resolve_skill_commands_home()
# Build into a local map and publish once, at the end. Writing straight
# into the global made a scan's partial results visible to everything
# else in the process: a second, overlapping scan deduped against its own
# (empty) ``seen_names`` but collided against the first scan's already-
# published slugs, logging one bogus "already claimed" warning per skill —
# each naming the same skill as its own incumbent (#74574).
commands: Dict[str, Dict[str, Any]] = {}
try:
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, skill_matches_environment, _get_disabled_skill_names
from agent.skill_utils import (
@@ -505,14 +516,14 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
# slug (e.g. "git_helper" vs "git-helper"). First-wins
# preserves local-before-external precedence.
cmd_key = f"/{cmd_name}"
if cmd_key in _skill_commands:
if cmd_key in commands:
logger.warning(
"Skill %r maps to slash command %s already claimed "
"by %r; keeping the first and skipping this one.",
name, cmd_key, _skill_commands[cmd_key]["name"],
name, cmd_key, commands[cmd_key]["name"],
)
continue
_skill_commands[cmd_key] = {
commands[cmd_key] = {
"name": name,
"description": description or f"Invoke the {name} skill",
"skill_md_path": str(skill_md),
@@ -522,7 +533,18 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
continue
except Exception:
pass
return _skill_commands
# Publish the finished map and the platform/home it was scanned for as
# ONE step. Bare assignments are not atomic together: a reader landing
# between them sees the NEW map still carrying the OLD platform tag, and
# if that stale tag happens to match its own platform it accepts the map
# without rescanning — serving another platform's disabled-skill view,
# exactly the leak #14536 closed. Only the publish/lookup pair is locked;
# the scan above (file I/O, deferred imports) stays outside it.
with _publish_lock:
_skill_commands = commands
_skill_commands_platform = platform
_skill_commands_home = home
return commands
def get_skill_commands() -> Dict[str, Dict[str, Any]]:
@@ -534,13 +556,22 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]:
active profile's Hermes home changes (e.g. Desktop switching profiles
mid-session) so each profile sees its own ``skills.external_dirs`` (#88023).
"""
if (
not _skill_commands
or _skill_commands_platform != _resolve_skill_commands_platform()
or _skill_commands_home != _resolve_skill_commands_home()
):
scan_skill_commands()
return _skill_commands
current_platform = _resolve_skill_commands_platform()
current_home = _resolve_skill_commands_home()
# Read the map and its tags under the same lock that publishes them, so
# the freshness decision is made against a consistent snapshot.
with _publish_lock:
commands = _skill_commands
is_fresh = (
bool(commands)
and _skill_commands_platform == current_platform
and _skill_commands_home == current_home
)
if is_fresh:
return commands
# Scan outside the lock — it does file I/O and deferred imports, and
# concurrent scans are already safe (each builds its own map).
return scan_skill_commands()
def reload_skills() -> Dict[str, Any]:
+31 -7
View File
@@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional
from agent.prompt_builder import (
DEFAULT_AGENT_IDENTITY,
EXECUTION_GUIDANCE_MODELS,
GOOGLE_MODEL_OPERATIONAL_GUIDANCE,
HERMES_AGENT_HELP_GUIDANCE,
KANBAN_GUIDANCE,
@@ -477,13 +478,36 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
# paths, parallel tool calls, verify-before-edit, etc.)
if "gemini" in _model_lower or "gemma" in _model_lower:
stable_parts.append(GOOGLE_MODEL_OPERATIONAL_GUIDANCE)
# OpenAI GPT/Codex execution discipline (tool persistence,
# prerequisite checks, verification, anti-hallucination).
# Also applied to xAI Grok — same failure modes (claims completion
# without tool calls, suggests workarounds instead of using
# existing tools, replies with plans instead of executing).
if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower:
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
# Execution-discipline guidance (tool persistence, mandatory tool use
# for arithmetic, external-write read-back, count reconciliation,
# literal preservation, verification-gated completion). Historically
# nested inside the tool-use-enforcement branch and fenced to
# gpt/codex/grok; now an independent gate so DeepSeek/Kimi/Qwen-class
# models receive it even when tool_use_enforcement is off. Controlled
# by config.yaml agent.execution_guidance:
# "auto" (default) — matches EXECUTION_GUIDANCE_MODELS
# true — always inject (all models)
# false — never inject
# list — custom model-name substrings to match
# Resolved once at session start keyed on the (fixed) model name, so
# the system prompt stays byte-stable for the life of the conversation.
if agent.valid_tool_names:
_exec_guidance = getattr(agent, "_execution_guidance", "auto")
_exec_inject = False
if _exec_guidance is True or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"true", "always", "yes", "on"}):
_exec_inject = True
elif _exec_guidance is False or (isinstance(_exec_guidance, str) and _exec_guidance.lower() in {"false", "never", "no", "off"}):
_exec_inject = False
elif isinstance(_exec_guidance, list):
model_lower = (agent.model or "").lower()
_exec_inject = any(p.lower() in model_lower for p in _exec_guidance if isinstance(p, str))
else:
# "auto" or any unrecognised value — use hardcoded defaults
model_lower = (agent.model or "").lower()
_exec_inject = any(p in model_lower for p in EXECUTION_GUIDANCE_MODELS)
if _exec_inject:
stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE)
has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
if has_skills_tools:
+71 -1
View File
@@ -557,7 +557,11 @@ def make_tool_result_message(
The outer list itself is rebuilt rather than returned by identity, so
callers should compare by value, not by ``is``.
"""
wrapped = _maybe_wrap_untrusted(name, content)
# Order matters: detect provider-side elision on the RAW content and
# append the notice first, THEN wrap — so the notice lives inside the
# untrusted block next to the data it describes, appended exactly once
# at construction time (cache-safe).
wrapped = _maybe_wrap_untrusted(name, _maybe_append_elision_notice(name, content))
message = stamp_message_timestamp({
"role": "tool",
"name": name,
@@ -608,6 +612,70 @@ def _is_untrusted_tool(name: Optional[str]) -> bool:
return any(name.startswith(p) for p in _UNTRUSTED_TOOL_PREFIXES)
# --- Upstream-elision detection --------------------------------------------
#
# Some MCP servers elide data SERVER-SIDE and mark the elision inside the
# payload itself (e.g. Composio: '...13 more items' inside a JSON array,
# '"has_more": true', 'Complete response was large (N tokens). Full data
# saved to sandbox in /mnt/files/...', 'data_preview' envelopes). Because the
# result looks structurally complete, models treat the visible slice as the
# whole dataset and falsely claim completeness. When one of these markers is
# present, we append ONE compact notice at result-construction time — before
# the message enters history, never mutated later, so prompt caching is safe.
# Conservative patterns only: each one is an explicit provider-side "there is
# more data than what you can see" signal, not a generic truncation heuristic.
_UPSTREAM_ELISION_PATTERNS = (
re.compile(r"\.\.\.\s*\d+\s+more\s+items?", re.IGNORECASE),
re.compile(r'"has_more"\s*:\s*true', re.IGNORECASE),
re.compile(r"saved to sandbox", re.IGNORECASE),
re.compile(r"data_preview", re.IGNORECASE),
)
# Results smaller than this can't meaningfully hide an elided enumeration —
# skip the scan entirely so tiny results pay nothing.
_ELISION_SCAN_MIN_CHARS = 1_000
# Bound the regex scan: markers appear near the elided structure, which for
# the payload sizes that matter (20-50K) is always inside the first 64KB.
_ELISION_SCAN_MAX_CHARS = 65_536
_UPSTREAM_ELISION_NOTICE = (
'\n[hermes note: this result contains provider-side elision markers '
'(e.g. "...N more items" / has_more:true). The data shown is INCOMPLETE '
'— page/fetch the remainder before treating any enumeration as complete.]'
)
def _detect_upstream_elision(content: Any) -> bool:
"""True when a string tool result carries provider-side elision markers.
Cheap and safe by construction: non-string content is never scanned,
results under ``_ELISION_SCAN_MIN_CHARS`` short-circuit, and the regex
scan is capped at the first ``_ELISION_SCAN_MAX_CHARS`` chars.
"""
if not isinstance(content, str):
return False
if len(content) < _ELISION_SCAN_MIN_CHARS:
return False
window = content[:_ELISION_SCAN_MAX_CHARS]
return any(p.search(window) for p in _UPSTREAM_ELISION_PATTERNS)
def _maybe_append_elision_notice(name: str, content: Any) -> Any:
"""Append the incompleteness notice to untrusted string results that
embed upstream elision markers. Returns ``content`` unchanged otherwise.
Runs on the RAW result before untrusted-wrapping so the notice sits with
the data it describes, and only at result-construction time (cache-safe).
"""
if not _is_untrusted_tool(name):
return content
if _detect_upstream_elision(content):
return content + _UPSTREAM_ELISION_NOTICE
return content
def _tool_output_risk_metadata(name: str, content: Any) -> Optional[Dict[str, Any]]:
"""Classify textual attacker-controlled output without retaining a copy.
@@ -729,5 +797,7 @@ __all__ = [
"_extract_landed_file_mutation_paths",
"_extract_error_preview",
"_trajectory_normalize_msg",
"_detect_upstream_elision",
"_maybe_append_elision_notice",
"make_tool_result_message",
]
+32 -1
View File
@@ -87,7 +87,10 @@ def _budget_for_agent(agent) -> BudgetConfig:
"""
try:
ctx = getattr(getattr(agent, "context_compressor", None), "context_length", None)
return budget_for_context_window(int(ctx)) if ctx else DEFAULT_BUDGET
# budget_for_context_window(None) (rather than DEFAULT_BUDGET) so the
# config-driven MCP threshold override still applies when the context
# length isn't resolvable.
return budget_for_context_window(int(ctx) if ctx else None)
except Exception:
return DEFAULT_BUDGET
@@ -2120,6 +2123,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
question=next_args.get("question", ""),
choices=next_args.get("choices"),
multi_select=next_args.get("multi_select", False),
questions=next_args.get("questions"),
callback=agent.clarify_callback,
)
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
@@ -2196,6 +2200,33 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('read_window_below', function_args, tool_duration, result=function_result)}")
elif function_name == "tour":
def _execute(next_args: dict) -> Any:
from tools.tour_tool import tour_tool as _tour_tool
return _tour_tool(
action=next_args.get("action", ""),
surface=next_args.get("surface"),
selector=next_args.get("selector"),
title=next_args.get("title"),
text=next_args.get("text"),
side=next_args.get("side"),
steps=next_args.get("steps"),
step_index=next_args.get("step_index"),
callback=getattr(agent, "tour_callback", None),
)
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
agent,
function_name=function_name,
function_args=function_args,
effective_task_id=effective_task_id,
tool_call_id=getattr(tool_call, "id", "") or "",
execute=_execute,
scope_block=_ts_scope_block,
display_index=i,
))
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('tour', function_args, tool_duration, result=function_result)}")
elif function_name == "setup_mcp":
def _execute(next_args: dict) -> Any:
from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool
+88
View File
@@ -59,6 +59,39 @@ MUTATING_TOOL_NAMES = frozenset(
}
)
# Tools that are legitimately re-invoked with identical arguments and may
# legitimately return an unchanged result while waiting on external progress —
# background-process management and job pollers. The identical-call loop
# notice (agent.stall_guards) never fires for these, so polling patterns like
# ``process(action="poll")`` or repeatedly checking a generation job stay
# unannotated.
STALL_GUARD_REPEATABLE_TOOLS = frozenset(
{
"process",
"bfl_flux3_get_result",
}
)
# Poller naming conventions (e.g. ``<vendor>_get_result``) used by generated /
# MCP tool surfaces. Matched as suffixes so vendor-prefixed pollers are exempt
# without enumerating every vendor.
_STALL_GUARD_REPEATABLE_SUFFIXES = (
"_get_result",
"_poll",
)
# The notice fires on the Nth consecutive identical call (same tool, same
# canonical args, same result). 3 tolerates one legitimate double-check while
# catching the observed re-issue loops (3x/4x identical calls in eval traces).
STALL_GUARD_IDENTICAL_CALL_THRESHOLD = 3
def is_stall_guard_repeatable(tool_name: str) -> bool:
"""Whether a tool is exempt from the identical-call loop notice."""
if tool_name in STALL_GUARD_REPEATABLE_TOOLS:
return True
return tool_name.endswith(_STALL_GUARD_REPEATABLE_SUFFIXES)
@dataclass(frozen=True)
class ToolCallGuardrailConfig:
@@ -282,6 +315,14 @@ class ToolCallGuardrailController:
self._same_tool_failure_counts: dict[str, int] = {}
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
self._halt_decision: ToolGuardrailDecision | None = None
# Identical-call loop-breaker state (agent.stall_guards): tracks the
# CONSECUTIVE streak of identical (tool, canonical args) calls whose
# results were also identical. Any different call — or a different
# result — resets the streak, so legitimate re-reads after edits and
# varied polling are never flagged. Per-turn, like everything else here.
self._identical_streak_sig: ToolCallSignature | None = None
self._identical_streak_result_hash: str = ""
self._identical_streak_count: int = 0
# Per-turn runaway-loop cap counters. Reset every turn (this method
# runs at the start of each run_conversation), so the caps bound a
# single agent loop rather than accumulating across the session.
@@ -444,6 +485,53 @@ class ToolCallGuardrailController:
return False
return tool_name in self.config.idempotent_tools
def observe_identical_call(
self,
tool_name: str,
args: Mapping[str, Any] | None,
result: str | None,
) -> str | None:
"""Track consecutive identical calls; return a loop-breaker notice or None.
Fires the compact notice when the SAME tool is called with identical
canonical arguments AND returns an identical result for the
``STALL_GUARD_IDENTICAL_CALL_THRESHOLD``-th (and every subsequent)
consecutive time within the turn. Purely observational — never blocks
the call. Allowlisted pollers (``is_stall_guard_repeatable``) are
exempt, and any intervening different call or changed result resets
the streak. Callers append the returned notice to the tool RESULT at
construction time, which is cache-safe: tool results are append-only
and never mutate already-sent context.
"""
if is_stall_guard_repeatable(tool_name):
# Don't let a poller streak carry over into the next tool either.
self._identical_streak_sig = None
self._identical_streak_count = 0
return None
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
result_hash = _result_hash(result)
if (
self._identical_streak_sig == signature
and self._identical_streak_result_hash == result_hash
):
self._identical_streak_count += 1
else:
self._identical_streak_sig = signature
self._identical_streak_result_hash = result_hash
self._identical_streak_count = 1
count = self._identical_streak_count
if count < STALL_GUARD_IDENTICAL_CALL_THRESHOLD:
return None
ordinal = f"{count}{'th' if 11 <= count % 100 <= 13 else {1: 'st', 2: 'nd', 3: 'rd'}.get(count % 10, 'th')}"
return (
f"[hermes note: this is the {ordinal} consecutive identical call to "
f"{tool_name} with identical arguments returning the same result. "
"Do not repeat it — change arguments, use a different tool, or "
"proceed with what you have.]"
)
def _check_loop_cap(
self,
tool_name: str,
+48 -16
View File
@@ -13,6 +13,15 @@ import json
from typing import Any, Dict
from agent.lmstudio_reasoning import resolve_lmstudio_effort
from agent.reasoning_effort import (
KIMI_K3_EFFORTS,
KIMI_K3_OVERRIDES,
OPENAI_COMPAT_WIRE_EFFORTS,
TOKENHUB_EFFORTS,
clamp_effort,
kimi_supported_efforts,
requested_effort,
)
from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools
from agent.prompt_builder import DEVELOPER_ROLE_MODELS
from agent.transports.base import ProviderTransport
@@ -84,15 +93,25 @@ def _add_prompt_cache_key(
def _reasoning_config_for_model(model: str, reasoning_config: dict | None) -> dict | None:
"""Return the model's wire-compatible reasoning config."""
"""Return the model's wire-compatible reasoning config.
Hermes' internal effort set extends the wire vocabulary with ``ultra``
(the /reasoning command documents none..xhigh|max|ultra). OpenAI-
compatible wires — OpenRouter chief among them — accept exactly
max|xhigh|high|medium|low|minimal|none and reject the extension with
HTTP 400 (#89503). Clamp against the declared wire vocabulary via the
shared policy in ``agent.reasoning_effort``; provider profiles with
narrower sets clamp again downstream.
"""
if not isinstance(reasoning_config, dict):
return reasoning_config
if (
"gpt-5.6" in (model or "").lower()
and str(reasoning_config.get("effort") or "").strip().lower() == "ultra"
):
effort = str(reasoning_config.get("effort") or "").strip().lower()
if not effort:
return reasoning_config
clamped = clamp_effort(effort, OPENAI_COMPAT_WIRE_EFFORTS)
if clamped != effort:
normalized = dict(reasoning_config)
normalized["effort"] = "max"
normalized["effort"] = clamped
return normalized
return reasoning_config
@@ -559,11 +578,22 @@ class ChatCompletionsTransport(ProviderTransport):
and reasoning_config.get("enabled") is False
)
if not _kimi_thinking_off:
_kimi_effort = "medium"
if reasoning_config and isinstance(reasoning_config, dict):
_e = (reasoning_config.get("effort") or "").strip().lower()
if _e in {"low", "medium", "high"}:
_kimi_effort = _e
# Kimi vocabularies are declared in agent.reasoning_effort:
# K3 = low/high/max (with the vendor-documented medium→high,
# xhigh→max rounding), K2-era = low/medium/high. Default when
# no effort was requested: K3's server default is high,
# K2-era's is medium.
_supported = kimi_supported_efforts(model)
_overrides = (
KIMI_K3_OVERRIDES if _supported is KIMI_K3_EFFORTS else None
)
_e = requested_effort(reasoning_config)
if _e is None:
_kimi_effort = (
"high" if _supported is KIMI_K3_EFFORTS else "medium"
)
else:
_kimi_effort = clamp_effort(_e, _supported, _overrides)
api_kwargs["reasoning_effort"] = _kimi_effort
# Tencent TokenHub: top-level reasoning_effort (unless thinking disabled)
@@ -574,11 +604,13 @@ class ChatCompletionsTransport(ProviderTransport):
and reasoning_config.get("enabled") is False
)
if not _tokenhub_thinking_off:
_tokenhub_effort = "high"
if reasoning_config and isinstance(reasoning_config, dict):
_e = (reasoning_config.get("effort") or "").strip().lower()
if _e in {"low", "medium", "high"}:
_tokenhub_effort = _e
# TokenHub accepts low/medium/high (declared in
# agent.reasoning_effort); default high when no effort was
# requested.
_e = requested_effort(reasoning_config)
_tokenhub_effort = (
"high" if _e is None else clamp_effort(_e, TOKENHUB_EFFORTS)
)
api_kwargs["reasoning_effort"] = _tokenhub_effort
# LM Studio: top-level reasoning_effort. Only emit when the model
+29 -21
View File
@@ -27,6 +27,13 @@ def _cache_scope_from_session_id(session_id: Optional[str]) -> str:
match = _CRON_SESSION_ID_RE.match(sid)
return match.group(1) if match else sid
from agent.reasoning_effort import (
ACTUAL_RELAY_EFFORTS,
XAI_GROK46_EFFORTS,
XAI_LEGACY_EFFORTS,
clamp_effort,
codex_supported_efforts,
)
from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall
@@ -432,30 +439,31 @@ class ResponsesApiTransport(ProviderTransport):
elif reasoning_config.get("effort"):
reasoning_effort = reasoning_config["effort"]
_effort_clamp = {"minimal": "low"}
if "gpt-5.6" in (model or "").lower():
# Ultra is the Codex product tier; the Responses API wire value is max.
_effort_clamp["ultra"] = "max"
# Wire vocabularies are declared in agent.reasoning_effort; the shared
# clamp policy (nearest weaker supported level, never escalate,
# never invert the ladder) replaces the per-backend hand maps that
# repeatedly leaked internal levels like "ultra" to the wire
# (#89503 class) or clamped one rung below a model's real ceiling
# (#87279).
if params.get("is_xai_responses", False):
from agent.model_metadata import is_grok_46_family
# Grok 4.6 accepts xhigh as a wire value; older Grok models top
# out at high. max/ultra are Hermes ladder aliases for "this
# model's ceiling", so they clamp to the strongest level the
# model actually accepts — xhigh on grok-4.6, high elsewhere —
# never one rung below it (#87279).
if is_grok_46_family(model):
_effort_clamp.update({"max": "xhigh", "ultra": "xhigh"})
else:
_effort_clamp["xhigh"] = "high"
_effort_clamp.update({"max": "high", "ultra": "high"})
if (params.get("provider") or "").strip().lower() == "actual":
# Actual Computer relays to SGLang/vLLM backends that accept only
# none/low/medium/high/max for reasoning effort — a forwarded
# xhigh/ultra fails with a wrapped HTTP 400 ("Expecting value:
# line 1 column 1"). Clamp Hermes' wider set to the supported one.
_effort_clamp.update({"xhigh": "high", "ultra": "max"})
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
# Grok 4.6 accepts xhigh as a wire value; older Grok tops out
# at high.
_supported = (
XAI_GROK46_EFFORTS if is_grok_46_family(model)
else XAI_LEGACY_EFFORTS
)
elif (params.get("provider") or "").strip().lower() == "actual":
# Actual Computer relays to SGLang/vLLM backends:
# none/low/medium/high/max.
_supported = ACTUAL_RELAY_EFFORTS
else:
# OpenAI/Codex Responses backend — per-model vocabulary
# (live-verified: "max" is gpt-5.6-only, "minimal" always
# rejected). #68365 premise confirmed.
_supported = codex_supported_efforts(model)
reasoning_effort = clamp_effort(reasoning_effort, _supported)
response_tools = _responses_tools(tools)
+9
View File
@@ -605,6 +605,15 @@ def build_turn_context(
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Wall-clock run budget: per-run_conversation clock. Only stamped when a
# budget is configured so the default path stays clock-free; the wrap-up
# latch resets each turn (one notice per run, not per session).
if getattr(agent, "run_budget_seconds", None):
agent._run_budget_started_at = time.time()
else:
agent._run_budget_started_at = None
agent._run_budget_wrapup_injected = False
# Log conversation turn start for debugging/observability.
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
+12
View File
@@ -132,6 +132,18 @@ def get_active_provider() -> Optional[VideoGenProvider]:
except Exception as exc:
logger.debug("Could not read video_gen.provider from config: %s", exc)
# The managed "Nous Subscription" selection is serviced by the FAL
# plugin through the managed fal-queue gateway (the plugin's resolver
# routes managed when the stored selection is "nous").
if configured:
try:
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER
if configured.lower() == NOUS_MANAGED_PROVIDER:
configured = "fal"
except Exception: # pragma: no cover — helpers are in-repo
pass
with _lock:
snapshot = dict(_providers)
snapshot.update(_scoped_providers.get(hermes_home_key(), {}))
+16
View File
@@ -126,6 +126,22 @@ class WebSearchProvider(abc.ABC):
"""Return True if this provider implements :meth:`search`."""
return True
def is_keyless_available(self) -> bool:
"""Return True when this provider can serve calls WITHOUT credentials.
A separate, weaker tier than :meth:`is_available`: providers with a
public anonymous free tier (Exa / Parallel MCP endpoints) return
True here so the registry can fall back to them when NO provider is
configured or keyed — and only then. Keyless availability must never
make :meth:`is_available` return True, or the legacy preference walk
would route users with real credentials for a lower-priority backend
onto the free tier of a higher-priority one.
Like :meth:`is_available`, this must be cheap and must NOT make
network calls. Default: False.
"""
return False
def supports_extract(self) -> bool:
"""Return True if this provider implements :meth:`extract`.
+64
View File
@@ -166,6 +166,40 @@ _LEGACY_PREFERENCE = (
"ddgs",
)
# Keyless free-tier walk — strictly LAST-resort, tried only after the
# availability-filtered legacy walk finds nothing (i.e. the user has zero
# web credentials and no importable ddgs). These providers expose public
# anonymous MCP endpoints (see plugins/web/keyless_mcp.py). Like opencode,
# unpinned keyless traffic is split 50/50 between Exa and Parallel per
# process (see _keyless_preference()); an explicit `hermes tools` pick
# (web.backend / web.<capability>_backend) bypasses this walk entirely.
# Disable the tier with ``web.keyless_fallback: false``.
_KEYLESS_PREFERENCE = (
"exa",
"parallel",
)
def _keyless_preference() -> tuple:
"""Return the keyless walk order, split 50/50 per process.
Mirrors opencode's session-checksum A/B split between Exa and
Parallel: the per-process random session id (also used as Parallel's
free-tier rate-limit token) picks which vendor goes first, so keyless
load spreads evenly across both free tiers fleet-wide while staying
stable within one process. The runner-up stays in the walk as a
fallback if the first isn't registered. Explicit user selection never
reaches this function — configured names resolve in step 1.
"""
try:
from plugins.web.keyless_mcp import _SESSION_ID
if int(_SESSION_ID, 16) % 2:
return ("parallel", "exa")
except Exception as exc: # noqa: BLE001 — split is best-effort
logger.debug("keyless 50/50 split unavailable: %s", exc)
return _KEYLESS_PREFERENCE
def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearchProvider]:
"""Resolve the active provider for a capability ("search" | "extract").
@@ -254,9 +288,39 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
):
return provider
# 4. Keyless free-tier walk — the user has NO credentialed/importable
# backend at all. Fall back to providers that can serve anonymously
# (public MCP free tiers), unless disabled via
# ``web.keyless_fallback: false``. This tier never pre-empts a keyed
# setup: it is only reachable when the legacy walk found nothing.
if _keyless_tier_enabled():
for name in _keyless_preference():
provider = snapshot.get(name)
if provider is None or not _capable(provider):
continue
try:
if provider.is_keyless_available():
return provider
except Exception as exc: # noqa: BLE001 — buggy provider skipped
logger.debug(
"provider %s.is_keyless_available() raised %s", name, exc
)
return None
def _keyless_tier_enabled() -> bool:
"""Read ``web.keyless_fallback`` from config.yaml (default: enabled)."""
try:
from hermes_cli.config import load_config
web_cfg = load_config().get("web") or {}
return bool(web_cfg.get("keyless_fallback", True))
except Exception as exc: # noqa: BLE001 — config layer optional
logger.debug("keyless_fallback config read failed: %s", exc)
return True
def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]:
"""Return the plugin key of a *disabled* bundled web plugin that would
have provided the configured backend, or None.
+1 -1
View File
@@ -32,7 +32,7 @@
"clsx": "2.1.1",
"katex": "0.16.47",
"lucide-react": "0.577.0",
"nanostores": "1.4.0",
"nanostores": "1.4.2",
"radix-ui": "1.6.7",
"react": "19.2.7",
"react-dom": "19.2.7",
+17 -1
View File
@@ -151,6 +151,12 @@ Notes:
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
- Polymorph with `asChild` when the button must render as a link/Slot.
## Badges — one component
`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`,
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
## Form controls
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
@@ -190,6 +196,15 @@ Notes:
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
master/detail empties with an icon and action. Don't hand-roll a third
centered empty.
- **Confirmation:** `ConfirmDialog` is the only way we ask "are you sure". It
opens focused on Confirm, so `Enter` confirms and `Esc` cancels, and it owns
the pending → done → close beat and the inline error — a call site passes an
async `onConfirm` and nothing else. A third way out (e.g. "Remove from
sidebar" beside "Delete worktree") goes in the one `secondaryAction` slot.
Never `window.confirm`: it's an unstyled blocking Chromium modal. A handler
that wants the answer inline instead of a mounted dialog calls `confirm()`
from `src/store/confirm.ts`, which renders this same primitive through the
single `ConfirmHost` at the shell — the way `notify()` backs notifications.
## Chat, tools & boot surfaces
@@ -315,7 +330,8 @@ The detailed state contract lives in the scoped
## Before you add something — checklist
- [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`,
`ListRow`, `Loader`, `ErrorState`, `LogView`) instead of forking one?
`ListRow`, `Loader`, `ErrorState`, `LogView`, `ConfirmDialog`) instead of
forking one?
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
one-off shadows?
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
+3 -1
View File
@@ -182,7 +182,9 @@ Changing profiles or connection modes is a soft workspace switch, not another
cold boot. The shell and current management overlay remain mounted while
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
new connection repopulates skeletons. This prevents rows or transcripts from
the previous gateway bleeding into the next one.
the previous gateway bleeding into the next one. Switching changes only the
foreground view and request route: it does not cancel turns or stop a backend,
and retained background sockets continue receiving events from running jobs.
### Verification
+86
View File
@@ -0,0 +1,86 @@
/**
* E2E batch clarify test — the multi-question clarify card must mount ONCE.
*
* Regression coverage for the duplicated-card bug: `tool.start` carries the
* model's tool_call_id while `clarify.request` carries a gateway-generated
* request_id. A batch payload has no top-level `question`, so the two rows
* only merge when the correlation key comes from the question list
* (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that
* fix this exact flow rendered two identical interactive cards.
*
* The flow runs the real chain: composer → gateway → agent → clarify tool →
* clarify.request event → renderer, against the mock inference server.
*/
import { expect, test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('batch clarify card', () => {
test('renders exactly one card and completes via per-question locks', async () => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 })
await page.keyboard.press('Enter')
// The live batch form marks itself with data-clarify-batch=<count>.
const batchCard = page.locator('form[data-clarify-batch]')
await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 })
// THE regression assertion: one card, not two.
await expect(batchCard).toHaveCount(1)
await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length))
// Both questions render inside the single card.
for (const entry of BATCH_CLARIFY_QUESTIONS) {
await expect(batchCard.getByText(entry.question)).toHaveCount(1)
}
// Each question text also appears exactly once in the whole transcript —
// catches a duplicate that mounts outside a form[data-clarify-batch].
for (const entry of BATCH_CLARIFY_QUESTIONS) {
await expect(page.getByText(entry.question)).toHaveCount(1)
}
// Answer both questions: stage picks locally (no server traffic yet).
const confirmButton = batchCard.locator('button[type="submit"]')
await expect(confirmButton).toContainText('Confirm and continue')
await expect(confirmButton).toBeDisabled()
await batchCard.getByRole('button', { name: /Coffee/ }).click()
await expect(confirmButton).toBeDisabled()
await batchCard.getByRole('button', { name: /Morning/ }).click()
await expect(confirmButton).toBeEnabled()
// ONE confirm submits the whole batch.
await confirmButton.click()
// The settled card lists both questions with their locked answers.
const settled = page.locator('[data-clarify-settled]')
await settled.waitFor({ state: 'visible', timeout: 30_000 })
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible()
await expect(settled.getByText('Coffee', { exact: true })).toBeVisible()
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible()
await expect(settled.getByText('Morning', { exact: true })).toBeVisible()
// And still no duplicate live card lingering after settle.
await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0)
})
})
+19
View File
@@ -50,6 +50,25 @@ test.describe('dev-mode boot with mock backend', () => {
})
})
// A preload that throws never reaches contextBridge, so the renderer boots
// into "Desktop IPC bridge is unavailable" and every test below it dies on a
// 120s never-became-ready timeout instead. Checking the bridge by name makes
// that failure legible. The sandbox lets preload require only electron,
// events, timers and url — adding any other node builtin lands here.
test('the preload bridge reaches the renderer', async () => {
const bridge = await fixture!.page.evaluate(() => {
const desktop = (window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop
return {
present: typeof desktop,
glassSupported: typeof desktop?.glassSupported,
translucencySupported: typeof desktop?.translucencySupported
}
})
expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' })
})
test('backend boots and app becomes ready', async () => {
// This is the big one — wait for the full boot chain to complete:
// electron starts → hermes serve is spawned → WS connects → config
@@ -0,0 +1,123 @@
/**
* Context-menu edit verbs on real editables — the regressions jsdom cannot
* catch, exercised against the real renderer (real radix focus trap, real
* React unmount timing, real selection).
*
* The class under test: "Select all" from the app context menu must act on
* the FIELD the menu was opened on, never on the surrounding transcript.
* The first fix (focus-restore before dispatch) passed unit tests and still
* failed live because the radix trap steals focus back; the second fix runs
* selection renderer-side after the trap unmounts. These tests pin the
* observable outcome, not the mechanism.
*
* Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name
* includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also
* host-dependent.
*/
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { expect, test } from './test'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('select all from the composer context menu selects the draft, not the chat', async () => {
const page = fixture!.page
const composer = page.locator('[data-slot="composer-rich-input"]').first()
// Put a message into the transcript so there is chat text a document-wide
// select-all WOULD grab — the bug this test exists to catch. Wait for the
// mock reply to COMPLETE: while the turn is busy the composer is in its
// steer shape and a typed draft does not land in it.
await composer.click()
await composer.pressSequentially('transcript anchor message')
await page.keyboard.press('Enter')
await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, {
timeout: 60_000
})
// Draft text in the composer, then right-click it.
await composer.click()
await composer.pressSequentially('draft under selection')
await composer.click({ button: 'right' })
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
await selectAll.click()
// The selection must live inside the composer and cover exactly the draft.
await expect
.poll(
() =>
page.evaluate(() => {
const selection = window.getSelection()
const editable = document.querySelector('[data-slot="composer-rich-input"]')
if (!selection || selection.rangeCount === 0 || !editable) {
return { inside: false, text: '' }
}
return {
inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer),
text: selection.toString()
}
}),
{ timeout: 10_000 }
)
.toEqual({ inside: true, text: 'draft under selection' })
// Clear the draft so later tests start clean.
await page.keyboard.press('Delete')
})
test('cut, copy, and select all gray out in an empty composer', async () => {
const page = fixture!.page
const composer = page.locator('[data-slot="composer-rich-input"]').first()
await composer.click()
await composer.click({ button: 'right' })
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
await expect(selectAll).toHaveAttribute('data-disabled', /.*/)
await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/)
await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/)
await page.keyboard.press('Escape')
})
test('paste enables when the clipboard holds text', async () => {
const page = fixture!.page
const composer = page.locator('[data-slot="composer-rich-input"]').first()
// The empty-clipboard branch stays in the unit suite: the e2e app shares
// the SYSTEM clipboard, and writeText('') does not reliably clear it.
await page.evaluate(() =>
(
window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise<boolean> } }
).hermesDesktop?.writeClipboard?.('clipboard payload')
)
await composer.click()
await composer.click({ button: 'right' })
const paste = page.getByRole('menuitem', { name: /^Paste/ })
await paste.waitFor({ state: 'visible', timeout: 10_000 })
// The clipboard probe is an async IPC — the item enables when it lands.
await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull()
await page.keyboard.press('Escape')
})
+52
View File
@@ -339,6 +339,40 @@ const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
}
/**
* A marker that makes the mock emit a blocking BATCH clarify tool call
* (multi-question form). Regression coverage for the duplicated-card bug:
* the tool.start row and the clarify.request row carry different ids and a
* batch payload has no top-level question, so the correlation key must come
* from the question list or the card mounts twice.
*/
export const BATCH_CLARIFY_TRIGGER = 'E2E_BATCH_CLARIFY_TRIGGER'
export const BATCH_CLARIFY_QUESTIONS = [
{ question: 'Pick a batch drink?', choices: ['Coffee', 'Tea'] },
{ question: 'Pick a batch time?', choices: ['Morning', 'Night'] },
]
const BATCH_CLARIFY_TURN: ScriptedTurn = {
text: '',
toolCalls: [{ name: 'clarify', args: { questions: BATCH_CLARIFY_QUESTIONS } }],
}
function includesBatchClarifyTrigger(value: unknown): boolean {
if (typeof value === 'string') {
return value.includes(BATCH_CLARIFY_TRIGGER)
}
if (Array.isArray(value)) {
return value.some(includesBatchClarifyTrigger)
}
if (value && typeof value === 'object') {
return Object.values(value).some(includesBatchClarifyTrigger)
}
return false
}
function includesBlockingClarifyTrigger(value: unknown): boolean {
if (typeof value === 'string') {
return value.includes(BLOCKING_CLARIFY_TRIGGER)
@@ -484,6 +518,24 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
return
}
if (includesBatchClarifyTrigger(parsed.messages)) {
// Only the FIRST completion of the conversation scripts the batch
// clarify. The trigger text stays in message history, so once the
// answered tool result is present the turn falls through to the
// canned reply — otherwise the mock loops the quiz forever.
const hasToolResult = Array.isArray(parsed.messages)
&& parsed.messages.some((message: { role?: string }) => message?.role === 'tool')
if (!hasToolResult) {
if (stream) {
streamScriptedTurn(res, model, BATCH_CLARIFY_TURN)
} else {
nonStreamingScriptedTurn(res, model, BATCH_CLARIFY_TURN)
}
return
}
}
if (includesBlockingClarifyTrigger(parsed.messages)) {
if (stream) {
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
@@ -455,10 +455,13 @@ test('apiRequestRegistryConnectionId extracts a genuinely non-local connection i
assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1')
})
test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => {
test('apiRequestRegistryConnectionId preserves an explicit local registry route', () => {
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), 'local')
})
test('apiRequestRegistryConnectionId resolves null for unscoped legacy routes', () => {
assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null)
assert.equal(apiRequestRegistryConnectionId(null), null)
assert.equal(apiRequestRegistryConnectionId(undefined), null)
@@ -715,6 +718,37 @@ test('resolveProfileApiRequest scopes complete safe families according to their
)
})
test('resolveProfileApiRequest routes action-status polls with the action-spawning routes', () => {
// /api/actions/{name}/status must land on the SAME backend as the endpoints
// that spawn actions (skills hub install/uninstall/update, mcp catalog
// install): _spawn_hermes_action registers the dynamic action name only in
// the spawning process. Splitting the pair 404s the poll with
// "Unknown action: skills-install-<slug>-<hash>".
assert.deepEqual(
resolveProfileApiRequest('iris', '/api/actions/skills-install-ascii-art-dd7bccf1/status?lines=200', {
requestMethod: 'GET'
}),
{
backendProfile: null,
requestPath: '/api/actions/skills-install-ascii-art-dd7bccf1/status?lines=200&profile=iris'
}
)
// The spawn side (hub install) and the poll side must agree on the backend.
assert.deepEqual(
resolveProfileApiRequest('iris', '/api/skills/hub/install', {
requestMethod: 'POST'
}),
{ backendProfile: null, requestPath: '/api/skills/hub/install?profile=iris' }
)
// MCP catalog installs spawn background actions too — same pairing rule.
assert.deepEqual(
resolveProfileApiRequest('iris', '/api/mcp/catalog/install', {
requestMethod: 'POST'
}),
{ backendProfile: null, requestPath: '/api/mcp/catalog/install?profile=iris' }
)
})
test('resolveProfileApiRequest preserves remote routing precedence', () => {
assert.deepEqual(
resolveProfileApiRequest('iris', '/api/memory/reset', {
+20 -5
View File
@@ -548,7 +548,11 @@ const LOCAL_PRIMARY_SCOPED_ROUTES = new Set([
'GET /api/skills/hub/search',
'GET /api/skills/hub/sources',
'POST /api/skills/hub/uninstall',
'POST /api/skills/hub/update'
'POST /api/skills/hub/update',
// Spawns a background action polled via /api/actions/{name}/status — must
// live on the SAME backend as that poll family (below), or the poll asks a
// backend that never registered the dynamic action name and 404s.
'POST /api/mcp/catalog/install'
])
function localPrimaryRequestScope(opts: ProfileRouteOptions): boolean | null {
@@ -572,6 +576,16 @@ function localPrimaryRequestScope(opts: ProfileRouteOptions): boolean | null {
return true
}
// Action-status polls MUST land on the same backend as the endpoints that
// spawned them: `_spawn_hermes_action` registers the (often dynamic, e.g.
// `skills-install-<slug>-<hash>`) action name only in the spawning
// process's memory. Every action-spawning route above scopes to the
// primary, so the poll family follows — a pooled-backend poll 404s with
// "Unknown action" even though the install itself succeeded (#89xxx).
if (pathname.startsWith('/api/actions/')) {
return true
}
// Every current /api/tools handler accepts `profile`; every /api/profiles
// handler either aggregates profiles or names its target in the path/body.
// These are the only whole families safe to route through the primary.
@@ -750,15 +764,16 @@ function pathWithProfileScope(path, profile) {
/**
* Registry connection a REST request is explicitly pinned to, or null for the
* legacy profile-routed path. `''`/`'local'` mean the local pool — callers
* only detour through the registry for a genuinely non-local connection, so
* single-source users keep the byte-identical v1 route.
* legacy profile-routed path. An explicit `local` id must stay registry-scoped:
* when the v1 route is remote, only the registry resolver can force the request
* back to this device. Single-source users omit the id and keep the
* byte-identical v1 route.
*/
function apiRequestRegistryConnectionId(request): null | string {
const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : ''
const id = String(raw ?? '').trim()
if (!id || id === 'local') {
if (!id) {
return null
}
@@ -28,7 +28,10 @@ import {
REGISTRY_VERSION,
rememberSshEnumeration,
removeConnection,
resolvedConnectionId,
resolveRegistryLocalRoute,
setConnectionLaunchMode,
setLastUsedConnection,
setPrimaryConnection,
shouldDeferLocalEnumeration,
shouldRetrySshInventory,
@@ -54,6 +57,49 @@ test('labelSlug kebab-cases and never returns empty for non-empty input', () =>
assert.equal(labelSlug('!!!'), 'connection')
})
test('resolvedConnectionId identifies local and migrated remote descriptors', () => {
const registry = migrateV1ToRegistry({
mode: 'local',
profiles: {
personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' },
work: { mode: 'ssh', host: 'work-host', user: 'root' }
}
})
const personal = registry.connections.find(connection => connection.kind === 'remote')
const work = registry.connections.find(connection => connection.kind === 'ssh')
assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID)
assert.equal(
resolvedConnectionId(registry, {
baseUrl: 'https://personal.example:9443',
mode: 'remote',
remoteKind: 'url'
}),
personal?.id
)
assert.equal(
resolvedConnectionId(registry, {
baseUrl: 'http://127.0.0.1:49152',
mode: 'remote',
remoteHost: 'root@work-host',
remoteKind: 'ssh'
}),
work?.id
)
})
test('resolvedConnectionId does not guess an unregistered remote', () => {
assert.equal(
resolvedConnectionId(emptyRegistry(), {
baseUrl: 'https://unknown.example',
mode: 'remote',
remoteKind: 'url'
}),
null
)
})
test('agentHandle bare when unique, @name-device shape when duplicated', () => {
assert.equal(agentHandle('research', 'Homelab', false), 'research')
assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab')
@@ -644,6 +690,8 @@ test('normalizeRegistry degrades junk to a local-only registry', () => {
assert.equal(registry.version, REGISTRY_VERSION)
assert.equal(registry.primary, LOCAL_CONNECTION_ID)
assert.equal(registry.launchMode, 'primary')
assert.equal(registry.lastUsed, LOCAL_CONNECTION_ID)
assert.equal(registry.connections.length, 1)
assert.equal(registry.connections[0].kind, 'local')
}
@@ -675,6 +723,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
const input = {
version: 2,
primary: 'homelab',
launchMode: 'last-used',
lastUsed: 'homelab',
connections: [
{ id: 'local', kind: 'local', label: 'This device' },
{
@@ -700,6 +750,8 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
const registry = normalizeRegistry(input)
assert.equal(registry.primary, 'homelab')
assert.equal(registry.launchMode, 'last-used')
assert.equal(registry.lastUsed, 'homelab')
assert.equal(registry.connections.length, 4)
assert.deepEqual(
registry.connections.map(c => c.id),
@@ -709,6 +761,22 @@ test('normalizeRegistry round-trips a valid registry unchanged in shape', () =>
assert.equal(registry.connections[3].port, 2222)
})
test('normalizeRegistry falls back to Primary when the last-used source is missing', () => {
const registry = normalizeRegistry({
version: 2,
primary: 'homelab',
launchMode: 'last-used',
lastUsed: 'retired-host',
connections: [
{ id: 'local', kind: 'local', label: 'This device' },
{ id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }
]
})
assert.equal(registry.launchMode, 'last-used')
assert.equal(registry.lastUsed, 'homelab')
})
// --- v1 → v2 migration ---
test('migrate: v1 local-only config → local-only registry', () => {
@@ -793,17 +861,19 @@ test('migrate: duplicate host labels are suffixed, not dropped', () => {
// --- registry operations ---
test('removeConnection: local refuses, primary retargets to local', () => {
test('removeConnection: local refuses, primary and last-used retarget safely', () => {
let registry = emptyRegistry()
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
registry = upsertConnection(registry, entry)
registry = setPrimaryConnection(registry, entry.id)
registry = setLastUsedConnection(registry, entry.id)
assert.throws(() => removeConnection(registry, LOCAL_CONNECTION_ID), /cannot be removed/)
const after = removeConnection(registry, entry.id)
assert.equal(after.primary, LOCAL_CONNECTION_ID)
assert.equal(after.lastUsed, LOCAL_CONNECTION_ID)
assert.equal(after.connections.length, 1)
// Removing an unknown id is a no-op, not an error.
assert.equal(removeConnection(after, 'ghost'), after)
@@ -816,6 +886,17 @@ test('setPrimaryConnection validates the target id', () => {
assert.equal(setPrimaryConnection(registry, LOCAL_CONNECTION_ID).primary, LOCAL_CONNECTION_ID)
})
test('last-used source and launch mode validate their persisted values', () => {
let registry = emptyRegistry()
const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry)
registry = upsertConnection(registry, entry)
assert.throws(() => setLastUsedConnection(registry, 'ghost'), /No connection/)
assert.equal(setLastUsedConnection(registry, entry.id).lastUsed, entry.id)
assert.equal(setConnectionLaunchMode(registry, 'last-used').launchMode, 'last-used')
assert.throws(() => setConnectionLaunchMode(registry, 'sometimes'), /Unknown connection launch mode/)
})
test('upsertConnection replaces by id and appends new ids', () => {
let registry = emptyRegistry()
const a = normalizeConnectionInput({ kind: 'remote', label: 'A', url: 'http://a:1' }, registry)
+107 -4
View File
@@ -76,6 +76,11 @@ export interface ConnectionRegistry {
version: typeof REGISTRY_VERSION
/** id of the connection that owns the window/primary backend. */
primary: string
/** Which saved source Sessions should restore when the app launches. */
launchMode: 'last-used' | 'primary'
/** Last source the Sessions workspace successfully opened. Additive in v2
* so registries written before multi-source switching still normalize. */
lastUsed: string
connections: RegistryConnection[]
}
@@ -178,6 +183,79 @@ export interface RegistryLocalRoute {
poolKey: string
}
export interface ResolvedConnectionDescriptor {
baseUrl?: string
mode?: 'local' | 'remote'
remoteHost?: string
remoteKind?: 'cloud' | 'ssh' | 'url'
}
/**
* Recover registry identity for a descriptor resolved through the legacy v1
* profile path. Registry-scoped routes already carry `connectionId`; this
* bridge keeps migrated per-profile remotes truthful until v1 is retired.
*/
export function resolvedConnectionId(
registry: ConnectionRegistry,
descriptor: ResolvedConnectionDescriptor
): null | string {
if (descriptor.mode === 'local') {
return registry.connections.find(connection => connection.kind === 'local')?.id ?? null
}
if (descriptor.mode !== 'remote') {
return null
}
if (descriptor.remoteKind === 'ssh') {
const remoteHost = String(descriptor.remoteHost || '')
.trim()
.toLowerCase()
if (!remoteHost) {
return null
}
return (
registry.connections.find(connection => {
if (connection.kind !== 'ssh') {
return false
}
const host = String(connection.host || '')
.trim()
.toLowerCase()
const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host
return target === remoteHost
})?.id ?? null
)
}
let baseUrl = ''
try {
baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl)
} catch {
return null
}
return (
registry.connections.find(connection => {
if (connection.kind !== 'cloud' && connection.kind !== 'remote') {
return false
}
try {
return normalizeRemoteBaseUrl(connection.url) === baseUrl
} catch {
return false
}
})?.id ?? null
)
}
/**
* How the registry's 'local' entry resolves a backend for `profile`.
*
@@ -818,11 +896,15 @@ export function normalizeRegistry(raw: unknown): ConnectionRegistry {
connections.unshift(localEntry())
}
const primary = String(parsed.primary || '').trim()
const storedPrimary = String(parsed.primary || '').trim()
const primary = connections.some(c => c.id === storedPrimary) ? storedPrimary : LOCAL_CONNECTION_ID
const storedLastUsed = String(parsed.lastUsed || '').trim()
return {
version: REGISTRY_VERSION,
primary: connections.some(c => c.id === primary) ? primary : LOCAL_CONNECTION_ID,
primary,
launchMode: parsed.launchMode === 'last-used' ? 'last-used' : 'primary',
lastUsed: connections.some(c => c.id === storedLastUsed) ? storedLastUsed : primary,
connections
}
}
@@ -967,7 +1049,7 @@ export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry {
}
}
return { version: REGISTRY_VERSION, primary, connections }
return { version: REGISTRY_VERSION, primary, launchMode: 'primary', lastUsed: primary, connections }
}
/** Insert or replace by id. Input must already be normalized/validated. */
@@ -994,9 +1076,12 @@ export function removeConnection(registry: ConnectionRegistry, id: string): Conn
throw new Error('The local connection cannot be removed.')
}
const primary = registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary
return {
...registry,
primary: registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary,
primary,
lastUsed: registry.lastUsed === id ? primary : registry.lastUsed,
connections: registry.connections.filter(c => c.id !== id)
}
}
@@ -1009,3 +1094,21 @@ export function setPrimaryConnection(registry: ConnectionRegistry, id: string):
return { ...registry, primary: id }
}
/** Remember the last source the Sessions workspace opened successfully. */
export function setLastUsedConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry {
if (!registry.connections.some(c => c.id === id)) {
throw new Error(`No connection with id "${id}".`)
}
return { ...registry, lastUsed: id }
}
/** Choose whether launch restores the explicit primary or the last-used source. */
export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry {
if (launchMode !== 'last-used' && launchMode !== 'primary') {
throw new Error(`Unknown connection launch mode "${String(launchMode)}".`)
}
return { ...registry, launchMode }
}
+197
View File
@@ -0,0 +1,197 @@
// IPC surface for local filesystem operations the renderer's project/file
// surfaces use: directory reads, reveal/open in the OS file manager, plugin
// roots + git installs, rename/write/trash. Extracted from main.ts; path
// hardening, HERMES_HOME resolution, and the git binary stay injected.
import fs from 'node:fs'
import path from 'node:path'
import { ipcMain, shell } from 'electron'
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
import { readDirForIpc } from './fs-read-dir'
import { gitRootForIpc } from './git-root'
export interface FsIpcDeps {
hermesHome: string
readActiveDesktopProfile: () => null | string
expandUserPath: (value: string) => string
resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string
directoryExists: (value: string) => boolean
resolveGitBinary: () => string
}
export function registerFsIpc({
hermesHome,
readActiveDesktopProfile,
expandUserPath,
resolveRequestedPathForIpc,
directoryExists,
resolveGitBinary
}: FsIpcDeps) {
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
// Reveal a path in the OS file manager (Finder / Explorer / Files).
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
return false
}
try {
shell.showItemInFolder(target)
return true
} catch {
return false
}
})
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
// `reveal` (which selects an existing item and silently no-ops on a missing
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
// which often doesn't exist on first use. `shell.openPath` returns '' on
// success or an error string; both mkdir + openPath failures are surfaced.
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
const dir = String(dirPath || '').trim()
if (!dir) {
return { ok: false, error: 'no path' }
}
try {
await fs.promises.mkdir(dir, { recursive: true })
const error = await shell.openPath(path.normalize(dir))
return error ? { ok: false, error } : { ok: true }
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
})
// 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.
async function localPluginsRoot(dirName: string): Promise<string> {
// 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(hermesHome, 'profiles', profile) : hermesHome
const dir = path.join(base, dirName)
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
}
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
// resolution as above. This is the desktop half of a UNIFIED plugin package:
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
// this root for it — one installable folder serving both SDKs.
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
const identifier = String(payload?.identifier || payload?.repo || '').trim()
if (!identifier) {
return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] }
}
return probePluginRepo(resolveGitBinary(), identifier)
})
ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => {
const identifier = String(payload?.identifier || payload?.repo || '').trim()
if (!identifier) {
return { ok: false, error: 'identifier is required' }
}
const desktopPluginsRoot = await localPluginsRoot('desktop-plugins')
return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force))
})
// 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.
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
const src = String(targetPath || '').trim()
const name = String(newName || '').trim()
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
throw new Error('Invalid rename')
}
const dst = path.join(path.dirname(src), name)
if (dst === src) {
return { path: dst }
}
if (fs.existsSync(dst)) {
throw new Error(`"${name}" already exists`)
}
await fs.promises.rename(src, dst)
return { path: dst }
})
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
// this never creates directory trees or escapes the allowed roots, and content
// is size-capped so it can't be abused as a bulk-write primitive.
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
const raw = String(filePath || '').trim()
if (!raw) {
throw new Error('Invalid path')
}
const text = String(content ?? '')
if (text.length > 1_000_000) {
throw new Error('Content too large')
}
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
if (!directoryExists(path.dirname(resolved))) {
throw new Error('Parent directory does not exist')
}
await fs.promises.writeFile(resolved, text, 'utf8')
return { path: resolved }
})
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
const target = String(targetPath || '').trim()
if (!target) {
throw new Error('Invalid delete')
}
await shell.trashItem(target)
return true
})
}
+120
View File
@@ -0,0 +1,120 @@
// IPC surface for git-driven features: worktree management ("Start work"),
// the composer coding rail's repo status, the Codex-style review pane, and
// repo-first project discovery. Extracted from main.ts; the git/gh binary
// resolvers stay injected because main.ts also uses them for self-update and
// plugin installs.
import { ipcMain } from 'electron'
import { scanGitRepos } from './git-repo-scan'
import {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewFetchPrComment,
reviewList,
reviewPrList,
reviewPush,
reviewRevert,
reviewRevParse,
reviewShipInfo,
reviewStage,
reviewUnstage
} from './git-review-ops'
import {
addWorktree,
listBaseBranches,
listBranches,
listWorktrees,
removeWorktree,
switchBranch
} from './git-worktree-ops'
export interface GitIpcDeps {
resolveGitBinary: () => string
resolveGhBinary: () => string
}
export function registerGitIpc({ resolveGitBinary, resolveGhBinary }: GitIpcDeps) {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:baseBranchList', async (_event, repoPath) =>
listBaseBranches(repoPath, resolveGitBinary())
)
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:prList', async (_event, repoPath, branches, numbers) =>
reviewPrList(repoPath, resolveGhBinary(), branches, numbers)
)
ipcMain.handle('hermes:git:review:fetchPrComment', async (_event, repoPath, url) =>
reviewFetchPrComment(repoPath, resolveGhBinary(), url)
)
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
}
+10 -18
View File
@@ -3,31 +3,23 @@ import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Relative, not `@hermes/shared`: the electron bundle is built by esbuild with
// no tsconfig path resolution (see scripts/bundle-electron-main.mjs), so a bare
// specifier would typecheck and then fail to bundle.
import {
clampDataUrlReadMaxMb,
DATA_URL_READ_DEFAULT_MAX_MB,
DATA_URL_READ_MAX_MAX_MB,
DATA_URL_READ_MIN_MAX_MB
} from '../../shared/src/data-url-read-max'
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
// 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
}
+192
View File
@@ -0,0 +1,192 @@
// IPC surface for HUD mode (the chrome-free floating chat band). Extracted
// from main.ts; the HUD window handle and session-id latch stay injected
// because main.ts owns the window lifecycle and the close broadcast reads the
// latch when handing the session back to the app window.
import { type BrowserWindow, ipcMain } from 'electron'
import { hudFrostFor, type TranslucencyState } from './translucency'
export interface HudIpcDeps {
isMac: boolean
isWindows: boolean
glassSupported: boolean
/** Main's authoritative translucency state (Settings → Appearance). */
getTranslucencyState: () => TranslucencyState
getHudWindow: () => BrowserWindow | null
openHudWindow: (sessionId: null | string, profile: null | string) => void
closeHudWindow: () => void
setHudSessionId: (sessionId: null | string) => void
}
export function registerHudIpc({
isMac,
isWindows,
glassSupported,
getTranslucencyState,
getHudWindow,
openHudWindow,
closeHudWindow,
setHudSessionId
}: HudIpcDeps) {
// Whether the band currently covers the window below the bar. The renderer
// is the only party that can know this (it measures the transcript), and it
// is half of the frost decision — the other half is the user's setting,
// which main owns. Latched so a Settings change can re-decide without
// waiting for the HUD to report again.
let bandShowing = false
let applied: null | string = null
let appliedTo: BrowserWindow | null = null
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. The material IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and it only turns on while the band is showing
// (idle HUD mode must be the bar and nothing else).
//
// Diffed before issuing: `setVibrancy` carries a 150ms animation that restarts
// if re-issued, so a repeated call would keep the material from ever settling
// (the same churn the chat windows' native-diff contract exists to prevent).
//
// The diff is keyed to the WINDOW as well as the value. A HUD respawn (the
// profile switch in openHudWindow destroys and rebuilds it) hands back a
// fresh window carrying no material, and a latch that only remembered the
// value would recognise its own last answer and skip — leaving the new HUD
// unfrosted until something else happened to change the signature.
const applyHudFrost = () => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed()) {
applied = null
appliedTo = null
return
}
const frost = hudFrostFor(getTranslucencyState(), bandShowing)
const signature = `${frost.vibrancy ?? 'off'}:${frost.backgroundMaterial}`
if (applied === signature && appliedTo === hudWindow) {
return
}
applied = signature
appliedTo = hudWindow
if (isMac && typeof hudWindow.setVibrancy === 'function') {
hudWindow.setVibrancy(frost.vibrancy)
}
if (isWindows && glassSupported && typeof hudWindow.setBackgroundMaterial === 'function') {
hudWindow.setBackgroundMaterial(frost.backgroundMaterial)
}
}
ipcMain.handle('hermes:hud:open', async (_event, request) => {
openHudWindow(
typeof request?.sessionId === 'string' ? request.sessionId : null,
typeof request?.profile === 'string' ? request.profile : null
)
return { ok: true }
})
ipcMain.handle('hermes:hud:frost', (_event, showing) => {
bandShowing = Boolean(showing)
applyHudFrost()
return { ok: true }
})
// Let clicks fall through the HUD wherever it isn't really there. An
// always-on-top window eats every click inside its rectangle, and most of that
// rectangle is a faded-out band over whatever the user is actually working in.
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
// reaches the bar.
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
ipcMain.on('hermes:hud:move-by', (event, delta) => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents) {
return
}
const dx = Number(delta?.x)
const dy = Number(delta?.y)
const width = Number(delta?.width)
const height = Number(delta?.height)
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(width) || !Number.isFinite(height)) {
return
}
const [x, y] = hudWindow.getPosition()
// setBounds — NOT setPosition: on Windows, a transparent frameless window
// silently grows ~1px per setPosition call (worse at >100% DPI). The renderer
// snapshots outerWidth/outerHeight when the composer drag arms and re-pins
// to that size on every moveBy (same pattern as the pet overlay drag).
hudWindow.setBounds({
x: Math.round(x + dx),
y: Math.round(y + dy),
width: Math.round(width),
height: Math.round(height)
})
})
// Resize from the HUD's corner handle. The window is created non-resizable
// (see spawnHudWindow — a transparent frameless window must not expose a
// system resize hot-zone, or dragging grows it), which on Windows/Linux also
// blocks programmatic setBounds sizing — so briefly flip resizable on while
// the size actually changes, exactly like the pet overlay's wheel-scale does.
ipcMain.on('hermes:hud:set-bounds', (event, bounds) => {
const hudWindow = getHudWindow()
if (!hudWindow || hudWindow.isDestroyed() || event.sender !== hudWindow.webContents || !bounds) {
return
}
const win = hudWindow
const width = Math.max(380, Math.round(Number(bounds.width)))
const height = Math.max(160, Math.round(Number(bounds.height)))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(Number(bounds.x)), y: Math.round(Number(bounds.y)), width, height })
if (resizing) {
win.setResizable(false)
}
})
// The HUD renderer reporting which session it is on, so the close broadcast
// can hand it back to the app window (see hudSessionId).
ipcMain.on('hermes:hud:session', (event, sessionId) => {
const hudWindow = getHudWindow()
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
setHudSessionId(typeof sessionId === 'string' && sessionId ? sessionId : null)
}
})
ipcMain.handle('hermes:hud:close', async () => {
closeHudWindow()
return { ok: true }
})
// Main re-applies the frost when the translucency SETTING changes, since the
// band's own report only fires when the band itself moves.
return { applyHudFrost }
}
@@ -1,81 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { imageContextMenuItems } from './image-context-menu'
function createActions() {
const calls = {
copyImageAt: [],
openImage: [],
copyImageAddress: [],
saveImage: []
}
return {
calls,
actions: {
copyImageAt: (x, y) => calls.copyImageAt.push([x, y]),
openImage: url => calls.openImage.push(url),
copyImageAddress: url => calls.copyImageAddress.push(url),
saveImage: url => calls.saveImage.push(url)
}
}
}
test('keeps Copy Image available when Chromium omits a large image srcURL', () => {
const { actions, calls } = createActions()
const items = imageContextMenuItems(
{ mediaType: 'image', hasImageContents: true, srcURL: '', x: 100, y: 120 },
actions
)
assert.deepEqual(
items.map(item => item.label),
['Copy Image']
)
items[0].click()
assert.deepEqual(calls.copyImageAt, [[100, 120]])
})
test('keeps URL-dependent image actions when srcURL is available', () => {
const { actions, calls } = createActions()
const url = 'https://example.com/image.png'
const items = imageContextMenuItems({ mediaType: 'image', hasImageContents: true, srcURL: url, x: 5, y: 8 }, actions)
assert.deepEqual(
items.map(item => item.label),
['Open Image', 'Copy Image', 'Copy Image Address', 'Save Image As...']
)
items[0].click()
items[1].click()
items[2].click()
items[3].click()
assert.deepEqual(calls.openImage, [url])
assert.deepEqual(calls.copyImageAt, [[5, 8]])
assert.deepEqual(calls.copyImageAddress, [url])
assert.deepEqual(calls.saveImage, [url])
})
test('does not add image actions for a non-image target', () => {
const { actions } = createActions()
assert.deepEqual(
imageContextMenuItems({ mediaType: 'none', hasImageContents: false, srcURL: '', x: 0, y: 0 }, actions),
[]
)
})
test('does not offer Copy Image when the target has no decoded image contents', () => {
const { actions } = createActions()
assert.deepEqual(
imageContextMenuItems({ mediaType: 'image', hasImageContents: false, srcURL: '', x: 0, y: 0 }, actions),
[]
)
})
@@ -1,40 +0,0 @@
export function imageContextMenuItems(params, actions) {
if (params.mediaType !== 'image' || !params.hasImageContents) {
return []
}
const items = []
const srcURL = params.srcURL || ''
if (srcURL) {
items.push({
label: 'Open Image',
click: () => {
if (!srcURL.startsWith('data:')) {
actions.openImage(srcURL)
}
},
enabled: !srcURL.startsWith('data:')
})
}
items.push({
label: 'Copy Image',
click: () => actions.copyImageAt(params.x, params.y)
})
if (srcURL) {
items.push(
{
label: 'Copy Image Address',
click: () => actions.copyImageAddress(srcURL)
},
{
label: 'Save Image As...',
click: () => actions.saveImage(srcURL)
}
)
}
return items
}
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ import { test } from 'vitest'
import {
oauthGuardMayHardFail,
oauthSessionIsLive,
resolveGatedDownloadAuth,
resolveJsonBody,
resolveOauthRestAuth,
resolveReadinessProbeAuth
@@ -130,3 +131,20 @@ test('oauthGuardMayHardFail keeps the strict guard when the list is unusable', (
assert.equal(oauthGuardMayHardFail('nonsense' as any), true)
assert.equal(oauthGuardMayHardFail([{ supportsPassword: true }]), true)
})
// --- 6. gated download auth (guards the Files-panel 401 on cookieless native) ---
test('resolveGatedDownloadAuth matches oauth REST: bearer first, then cookie', () => {
assert.deepEqual(resolveGatedDownloadAuth('oauth', 'native-at'), { kind: 'bearer', token: 'native-at' })
assert.deepEqual(resolveGatedDownloadAuth('oauth', null), { kind: 'cookie' })
assert.deepEqual(resolveGatedDownloadAuth('oauth', ''), { kind: 'cookie' })
})
test('resolveGatedDownloadAuth uses the session token for token and local modes', () => {
assert.deepEqual(resolveGatedDownloadAuth('token', 'native-at', 'session-token'), {
kind: 'token',
token: 'session-token'
})
assert.deepEqual(resolveGatedDownloadAuth('local', null, 'sess'), { kind: 'token', token: 'sess' })
assert.deepEqual(resolveGatedDownloadAuth(undefined, null, null), { kind: 'token', token: null })
})
+29 -2
View File
@@ -2,7 +2,7 @@
* native-auth-decisions.ts
*
* Pure decision helpers extracted from main.ts for the RFC 8252 native-app
* auth flow. These encode three choices that were each the site of a real
* auth flow. These encode six choices that were each the site of a real
* runtime bug — invisible to the mocked flow tests because the tests never
* exercised the real main.ts internals. Keeping them pure + unit-tested here
* prevents silent regressions:
@@ -31,7 +31,12 @@
* can satisfy neither the native-bearer nor the OAuth-partition-cookie
* check by design, so the pre-flight guard must not hard-fail it.
*
* All five are trivial once named; the value is the test that pins the
* 6. resolveGatedDownloadAuth — file save/read must present the SAME
* credentials as oauth REST. `saveGatewayFile` used to always ride the
* OAuth cookie partition, so a cookieless native (or native-password)
* session could list files via `hermes:api` and still 401 on Download.
*
* All six are trivial once named; the value is the test that pins the
* contract so the god-file call sites can't drift back to the buggy shape.
*/
@@ -106,6 +111,28 @@ export function resolveReadinessProbeAuth(
return { kind: 'public' }
}
export type GatedDownloadAuth = OauthRestAuth | { kind: 'token'; token: string | null }
/**
* Decide how a gated file download authenticates.
*
* Must match oauth REST (`resolveOauthRestAuth`): native bearer when present,
* else the OAuth cookie partition. Token/local connections keep the static
* session-token header. A cookie-only download against a cookieless native
* session is the #88987 401 — Files panel listing works, Download does not.
*/
export function resolveGatedDownloadAuth(
authMode: string | null | undefined,
nativeAccessToken?: string | null,
connectionToken?: string | null
): GatedDownloadAuth {
if (authMode === 'oauth') {
return resolveOauthRestAuth(nativeAccessToken)
}
return { kind: 'token', token: connectionToken ?? null }
}
export interface AdvertisedAuthProvider {
name?: string
supportsPassword?: boolean
+151
View File
@@ -0,0 +1,151 @@
// IPC surface for the pop-out pet overlay (mascot window). Extracted from
// main.ts; window handles stay injected because main.ts owns their lifecycle.
import { type BrowserWindow, ipcMain } from 'electron'
export interface PetOverlayIpcDeps {
getMainWindow: () => BrowserWindow | null
getPetOverlayWindow: () => BrowserWindow | null
openPetOverlay: (bounds: unknown) => void
closePetOverlay: () => void
}
export function registerPetOverlayIpc({
getMainWindow,
getPetOverlayWindow,
openPetOverlay,
closePetOverlay
}: PetOverlayIpcDeps) {
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
// bounds (screen=false): convert to screen space by adding the main window's
// content origin so the pet lands where it sat in-window. A remembered/dragged
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
// resolved screen bounds so the renderer can persist exactly where it opened.
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
const bounds = request && request.bounds ? request.bounds : request
const isScreen = Boolean(request && request.screen)
const mainWindow = getMainWindow()
let screenBounds = bounds
try {
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
const content = mainWindow.getContentBounds()
screenBounds = {
x: content.x + (bounds.x || 0),
y: content.y + (bounds.y || 0),
width: bounds.width,
height: bounds.height
}
}
} catch {
// Fall back to raw bounds if the window geometry is unavailable.
}
openPetOverlay(screenBounds)
return { ok: true, bounds: screenBounds }
})
ipcMain.handle('hermes:pet-overlay:close', async () => {
closePetOverlay()
return { ok: true }
})
// Drag/resize: the overlay reports new absolute screen bounds (it already knows
// the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale
// gesture grows/shrinks it so the sprite is never cropped by the window edge.
// The window is created non-resizable (no stray edge-drag on the transparent
// frameless panel), which on Windows/Linux also blocks programmatic setBounds
// sizing — so briefly flip resizable on whenever the size actually changes.
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
const petOverlayWindow = getPetOverlayWindow()
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
return
}
const win = petOverlayWindow
const width = Math.max(80, Math.round(bounds.width))
const height = Math.max(80, Math.round(bounds.height))
const [curW, curH] = win.getSize()
const resizing = width !== curW || height !== curH
if (resizing && !win.isResizable()) {
win.setResizable(true)
}
win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height })
if (resizing) {
win.setResizable(false)
}
})
// Click-through: the overlay window is a full rectangle but only the pet pixels
// should be interactive. The renderer toggles this as the cursor enters/leaves
// the sprite so transparent margins pass clicks to whatever is behind.
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
const petOverlayWindow = getPetOverlayWindow()
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
// The overlay is a non-activating panel (focusable:false) so it never steals
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
// while the composer is open, then back to non-activating when it closes.
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
const petOverlayWindow = getPetOverlayWindow()
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
return
}
petOverlayWindow.setFocusable(Boolean(focusable))
if (focusable) {
petOverlayWindow.focus()
}
})
// Main renderer → overlay: forward the latest pet state for the overlay to render.
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
const petOverlayWindow = getPetOverlayWindow()
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
}
})
// Overlay → main renderer: control messages (pop back in, composer submit).
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
const mainWindow = getMainWindow()
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Double-click toggles the app window: hide it away if it's up front, bring it
// back if it's minimized/buried. Pure window control — nothing for the
// renderer to do, so don't forward it.
if (payload && payload.type === 'toggle-app') {
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
mainWindow.show()
mainWindow.focus()
} else {
mainWindow.minimize()
}
return
}
// The mail icon means "take me to the app": raise the main window (it may be
// minimized or buried) before the renderer navigates to the latest thread.
if (payload && payload.type === 'open-app') {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
}
+31 -2
View File
@@ -1,6 +1,17 @@
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'
// Which translucency the OS can back. Asked synchronously because the renderer
// needs it before its first paint, and answered by main because deciding it
// needs `os.release()` — a sandboxed preload may only require electron, events,
// timers and url, so importing node:os here throws before contextBridge runs
// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined =>
// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades
// to an ordinary opaque window rather than a page thinned over nothing.
const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support')
contextBridge.exposeInMainWorld('hermesDesktop', {
glassSupported: translucencySupport?.glass === true,
translucencySupported: translucencySupport?.translucency === true,
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
@@ -64,7 +75,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta),
setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds),
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
// Whether the band covers the window below the bar. Main pairs it with the
// user's translucency setting to decide the native frost (macOS vibrancy /
// Windows 11 DWM backdrop) — see hudFrostFor.
setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing),
// The HUD tells main which session it is on; main hands that back to the
// app window when the HUD closes, so the app can re-home onto it.
setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId),
@@ -137,6 +151,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
save: payload => ipcRenderer.invoke('hermes:connections:save', payload),
remove: id => ipcRenderer.invoke('hermes:connections:remove', id),
setPrimary: id => ipcRenderer.invoke('hermes:connections:set-primary', id),
setLaunchMode: mode => ipcRenderer.invoke('hermes:connections:set-launch-mode', mode),
setLastUsed: id => ipcRenderer.invoke('hermes:connections:set-last-used', id),
test: id => ipcRenderer.invoke('hermes:connections:test', id),
// Fan out `hermes update` to every eligible registered connection.
updateAll: () => ipcRenderer.invoke('hermes:connections:update-all'),
@@ -185,6 +201,16 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'),
saveGatewayFile: payload => ipcRenderer.invoke('hermes:saveGatewayFile', payload),
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
contextMenuEdit: command => ipcRenderer.invoke('hermes:context-menu:edit', command),
contextMenuCopyImage: () => ipcRenderer.invoke('hermes:context-menu:copy-image'),
contextMenuSpellcheck: action => ipcRenderer.invoke('hermes:context-menu:spellcheck', action),
contextMenuGuestAddWord: payload => ipcRenderer.invoke('hermes:context-menu:guest-add-word', payload),
onContextMenuSpellcheck: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:context-menu-spellcheck', listener)
return () => ipcRenderer.removeListener('hermes:context-menu-spellcheck', listener)
},
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
getPathForFile: file => {
@@ -218,6 +244,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
zoom: {
// Current zoom of this window, as { level, percent }.
get: () => ipcRenderer.invoke('hermes:zoom:get'),
// Synchronous zoom factor (1 = 100%). Coordinate math needs it in the
// same tick as the event it converts, so no IPC round-trip here.
factor: () => webFrame.getZoomFactor(),
setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent),
// Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts,
// so the settings UI can stay in sync with the keyboard.
+13 -20
View File
@@ -28,6 +28,7 @@
import crypto from 'node:crypto'
import { parseRemoteProfileListing } from './connection-registry'
import { assertBootstrapNotSuperseded } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
// Bumped when the desktop<->dashboard reuse contract changes in a way that makes
@@ -558,7 +559,7 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT
const remoteLog = expandRemotePath(logPath)
while (Date.now() < deadline) {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
if (isAlive && !(await isAlive())) {
const err: any = new Error('Remote dashboard process exited before announcing its port.')
@@ -696,14 +697,6 @@ async function cancelForwardSafe(deps, localPort, remotePort) {
}
}
function assertNotAborted(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
function isForwardBindCollision(error) {
return /address already in use|cannot listen to port|bind.*failed/i.test(String(error?.message || error || ''))
}
@@ -769,7 +762,7 @@ async function connect(deps) {
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const platform = await probeRemotePlatform(ssh)
log(`remote platform ${platform.os}/${platform.arch}`)
const hermesPath = await locateHermes(ssh, remoteHermesPath)
@@ -810,7 +803,7 @@ async function connect(deps) {
lock.hermesHome === hermesHome
if (reusable) {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const localPort = await openForward(deps, lock.port)
try {
@@ -827,7 +820,7 @@ async function connect(deps) {
}
if (reuseClassification === 'authenticated-stale') {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
await cancelForwardSafe(deps, localPort, lock.port)
await cleanupStale(ssh, ownershipId, lock)
} else if (reuseClassification === 'authenticated-ok') {
@@ -840,7 +833,7 @@ async function connect(deps) {
'reused remote dashboard'
)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
return {
@@ -868,12 +861,12 @@ async function connect(deps) {
throw error
}
} else {
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
await cleanupStale(ssh, ownershipId, lock, pidAlive)
}
}
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const spawnToken = mintToken()
const { pid, spawnNonce, logPath, tokenFilePath } = await spawnRemoteDashboard(ssh, {
@@ -914,21 +907,21 @@ async function connect(deps) {
isAlive: () => remotePidAlive(ssh, pid),
signal
})
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
log(`remote dashboard bound port ${remotePort}`)
localPort = await openForward(deps, remotePort)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, spawnToken)
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const token = await adoptOwnedServedToken(adoptServedToken, baseUrl, spawnToken, ssh, pid, 'remote dashboard')
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
const tokenFingerprint = fingerprintToken(token)
await writeLockfile(ssh, ownershipId, { ...ownedSpawn, port: remotePort, tokenFingerprint })
assertNotAborted(signal)
assertBootstrapNotSuperseded(signal)
return {
baseUrl,
+12
View File
@@ -988,7 +988,19 @@ function createSshProbeConnection(config, options: any = {}) {
return new SshConnection(config, { ...options, mux: false })
}
// Bootstrap loops poll a remote for readiness; a newer attempt aborts the
// signal so the stale one stops polling and unwinds. `superseded` tells the
// caller this was replaced, not that it failed.
function assertBootstrapNotSuperseded(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
export {
assertBootstrapNotSuperseded,
baseSshOptions,
buildControlArgs,
buildExecArgs,
+377
View File
@@ -0,0 +1,377 @@
// The embedded terminal's PTY host: shell resolution, env scrubbing, session
// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the
// factory owns the session map and returns the dispose helpers main.ts needs
// for SSH teardown. findOnPath / logging / connection routing stay injected.
import { execFile } from 'node:child_process'
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { app, ipcMain } from 'electron'
import nodePty from 'node-pty'
import { resolveTerminalConnection } from './connection-apply'
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
import { buildInteractiveSshArgs } from './ssh-connection'
import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle'
export interface TerminalIpcDeps {
isWindows: boolean
findOnPath: (command: string) => null | string
rememberLog: (line: string) => void
activeSshTerminalTarget: () => unknown
ensureBackend: () => Promise<unknown>
getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string }
}
export interface TerminalIpcApi {
disposeTerminalSession: (id: string) => boolean
disposeTerminalSessionsForSshScope: (scope: string) => void
disposeAllTerminalSessions: () => void
}
export function registerTerminalIpc({
isWindows,
findOnPath,
rememberLog,
activeSshTerminalTarget,
ensureBackend,
getSshConnectionState
}: TerminalIpcDeps): TerminalIpcApi {
const terminalSessions = new Map()
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false
}
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
function posixShellSpec(shellPath) {
const shellName = path.basename(shellPath)
const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i']
return { args: interactiveArgs, command: shellPath, name: shellName }
}
// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box;
// prefer it only after PowerShell 7+ (`pwsh`).
function windowsPowerShellPath() {
const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe')
}
// Map a resolved shell path to its spawn spec, picking interactive flags by
// family: PowerShell drops its logo banner (so the prompt sits flush like the
// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…)
// gets POSIX interactive-login flags.
function shellSpecFor(shellPath) {
const name = path.basename(shellPath).toLowerCase()
if (name.startsWith('pwsh') || name.startsWith('powershell')) {
return { args: ['-NoLogo'], command: shellPath, name }
}
if (name.startsWith('cmd')) {
return { args: [], command: shellPath, name }
}
return posixShellSpec(shellPath)
}
// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell
// 5.1, then comspec/cmd.exe as the universal fallback.
function windowsShellSpec() {
const command =
findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe'
return shellSpecFor(command)
}
// Resolve the interactive shell for the embedded terminal: an explicit user
// override wins, otherwise auto-detect the best one installed for the platform.
function terminalShellCommand() {
// HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare
// name on PATH); $SHELL is honored on POSIX, where it's the user's canonical
// choice, but ignored on Windows, where it's usually a stray MSYS/Git path
// node-pty can't spawn natively.
const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim()
if (override) {
const resolved = isExecutableFile(override) ? override : findOnPath(override)
if (resolved) {
return shellSpecFor(resolved)
}
}
if (isWindows) {
return windowsShellSpec()
}
const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate))
return posixShellSpec(shellPath || '/bin/sh')
}
function safeTerminalCwd(cwd) {
const candidate = path.resolve(String(cwd || app.getPath('home')))
try {
const stat = fs.statSync(candidate)
return stat.isDirectory() ? candidate : path.dirname(candidate)
} catch {
return app.getPath('home')
}
}
function terminalShellEnv() {
const env = { ...process.env }
// Electron is commonly launched through `npm run dev`; do not leak npm's
// managed prefix into a user's interactive shell (nvm/proto warn loudly).
for (const key of Object.keys(env)) {
if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) {
delete env[key]
}
}
// Strip color/theme-detection vars that ride along when Electron is launched
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
delete env.NO_COLOR
delete env.FORCE_COLOR
delete env.COLORFGBG
env.COLORTERM = 'truecolor'
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
env.TERM = 'xterm-256color'
env.TERM_PROGRAM = 'Hermes'
env.TERM_PROGRAM_VERSION = app.getVersion()
// Let a hermes/--tui launched in this pane know it's embedded in the desktop
// GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP,
// which marks the agent *backend* and gates cron/gateway behavior.
env.HERMES_DESKTOP_TERMINAL = '1'
return env
}
function terminalChannel(id, suffix) {
return `hermes:terminal:${id}:${suffix}`
}
// Best-effort read of a live PTY child's current working directory so a
// reopened tab can restart the shell where the user last `cd`'d, instead of the
// tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on
// POSIX; Windows has no cheap per-process cwd query without a native module, so
// it returns null and the caller falls back to the launch cwd.
function readProcessCwd(pid) {
return new Promise(resolve => {
if (!Number.isInteger(pid) || pid <= 0) {
resolve(null)
return
}
if (process.platform === 'linux') {
fs.promises
.readlink(`/proc/${pid}/cwd`)
.then(target => resolve(target || null))
.catch(() => resolve(null))
return
}
if (process.platform === 'darwin') {
// lsof ships with macOS; -Fn emits the cwd fd's path on an `n<path>` line.
execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => {
if (err) {
resolve(null)
return
}
const line = String(stdout || '')
.split('\n')
.find(entry => entry.startsWith('n'))
resolve(line ? line.slice(1) : null)
})
return
}
resolve(null)
})
}
function disposeTerminalSession(id: string) {
const sessionInfo = terminalSessions.get(id)
if (!sessionInfo) {
return false
}
terminalSessions.delete(id)
try {
sessionInfo.pty.kill()
} catch {
// Process may already be gone.
}
return true
}
// SSH teardown: close every pane whose PTY rode the disconnected tunnel.
function disposeTerminalSessionsForSshScope(scope: string) {
for (const [id, info] of [...terminalSessions.entries()]) {
if (info.sshScope === scope) {
disposeTerminalSession(id)
}
}
}
// App shutdown: kill every open PTY before environment teardown.
function disposeAllTerminalSessions() {
for (const id of [...terminalSessions.keys()]) {
disposeTerminalSession(id)
}
}
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
// bit; the dev flow resolves node-pty straight from node_modules (nothing
// chmods it there), so the first terminal spawn dies with `posix_spawnp
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
// builds already stage an executable copy, so this is a no-op there.
let _spawnHelperEnsured = false
function ensureNodePtySpawnHelper() {
if (_spawnHelperEnsured || isWindows) {
return
}
_spawnHelperEnsured = true
try {
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
for (const helperPath of fixed) {
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
}
for (const failure of errors) {
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
}
} catch (error) {
rememberLog(
`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`
)
}
}
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureNodePtySpawnHelper()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24)
const sshTarget = await resolveTerminalConnection(activeSshTerminalTarget, ensureBackend)
const remote = Boolean(sshTarget)
const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null
const remoteCommand =
remoteState?.remotePlatform === 'Windows'
? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim())
: undefined
const ptyProcess = remote
? nodePty.spawn(
process.platform === 'win32'
? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe')
: 'ssh',
buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand),
{ cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows }
)
: nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows })
terminalSessions.set(id, {
pty: ptyProcess,
webContentsId: event.sender.id,
...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {})
})
const send = (suffix, payload) => {
if (event.sender.isDestroyed()) {
return
}
event.sender.send(terminalChannel(id, suffix), payload)
}
ptyProcess.onData(data => send('data', data))
ptyProcess.onExit(({ exitCode, signal }) => {
terminalSessions.delete(id)
send('exit', { code: exitCode, signal: signal || null })
})
event.sender.once('destroyed', () => disposeTerminalSession(id))
return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name }
})
ipcMain.handle('hermes:terminal:write', (_event, id, data) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
sessionInfo.pty.write(String(data || ''))
return true
})
ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return false
}
const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80)
const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24)
sessionInfo.pty.resize(cols, rows)
return true
})
ipcMain.handle('hermes:terminal:cwd', async (_event, id) => {
const sessionInfo = terminalSessions.get(String(id || ''))
if (!sessionInfo) {
return null
}
return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid)
})
ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || '')))
return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions }
}
+215 -12
View File
@@ -11,6 +11,7 @@
import { describe, expect, it } from 'vitest'
import {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
@@ -18,7 +19,11 @@ import {
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeMaterial,
normalizeMode,
normalizeScope,
@@ -28,9 +33,12 @@ import {
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowBackingOptions,
windowOpacityFor
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD
} from './translucency'
/** The linear ramp the curve replaced. Endpoints must still agree with it. */
@@ -38,13 +46,15 @@ const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7
const clear = (intensity: number): TranslucencyState => ({
intensity,
fade: 0,
mode: 'clear',
material: DEFAULT_GLASS_MATERIAL,
scope: DEFAULT_GLASS_SCOPE
})
const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL): TranslucencyState => ({
const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({
intensity,
fade,
mode: 'glass',
material,
scope: DEFAULT_GLASS_SCOPE
@@ -78,7 +88,7 @@ describe('clampIntensity', () => {
})
describe('normalizeMode', () => {
it('accepts glass on macOS only — there is no vibrancy to ride elsewhere', () => {
it('accepts glass only on a platform that has a native material', () => {
expect(normalizeMode('glass', true)).toBe('glass')
expect(normalizeMode('glass', false)).toBe('clear')
})
@@ -90,7 +100,7 @@ describe('normalizeMode', () => {
// Glass is pre-selected so the better half of the feature is the one you
// find, which is free because the intensity still starts at 0.
it('pre-selects glass on macOS when nothing is recorded', () => {
it('pre-selects glass when the platform supports it and nothing is recorded', () => {
expect(normalizeMode(undefined, true)).toBe('glass')
expect(normalizeMode('acrylic', true)).toBe('glass')
expect(normalizeMode(42, true)).toBe('glass')
@@ -163,11 +173,23 @@ describe('windowOpacityFor', () => {
expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX)))
})
it('never fades the native window in glass mode — the renderer paints that effect', () => {
// The tint is painted by the renderer, so the intensity lever must never
// reach setOpacity under glass — that separation is what keeps text sharp.
it('ignores the intensity lever entirely in glass mode', () => {
expect(windowOpacityFor(glass(0))).toBe(1)
expect(windowOpacityFor(glass(60))).toBe(1)
expect(windowOpacityFor(glass(100))).toBe(1)
})
it('fades a glass window only through its own lever, on the ramp clear uses', () => {
expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1)
expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40)))
expect(windowOpacityFor(glass(0, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100)))
})
it('leaves fade inert under clear, where the intensity lever already is the opacity', () => {
expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40)))
})
})
describe('glassSurfaceKeep', () => {
@@ -224,10 +246,172 @@ describe('vibrancyFor', () => {
})
})
// The HUD is a transparent window, so its frost has no opaque page to hide
// behind: every state that isn't "frost wanted" has to resolve to no material
// at all, or the band leaves a grey slab hanging over another app.
describe('hudFrostFor', () => {
it('wears the chosen frost on both platforms while the band is showing', () => {
expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' })
expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({
vibrancy: 'under-window',
backgroundMaterial: 'acrylic'
})
})
// The material is the whole window rectangle and nothing on the page can
// clip it, so a hidden band must mean no frost — this is the veto that keeps
// idle HUD mode the bar and nothing else.
it('is off whenever the band is not covering the window', () => {
expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
})
// ...and the setting is the other veto: Glass off, or the tint at zero,
// means the HUD never frosts however engaged the band is.
it('is off whenever glass itself is off', () => {
expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' })
})
// Unlike a chat window, which keeps 'sidebar' under its titlebar band in
// every non-glass state. Pinning this is what stops someone "fixing" the
// null into a resting material and painting the slab back.
it('resolves off to no material at all, not to a resting one', () => {
expect(hudFrostFor(clear(60), true).vibrancy).toBeNull()
expect(vibrancyFor(clear(60))).toBe('sidebar')
})
// The tint is painted by the renderer, exactly as it is for a chat window —
// dragging it must not re-issue setVibrancy, whose 150ms animation restarts
// on every call and never lets the material settle.
it('does not move any native property as the tint slider is dragged', () => {
for (let intensity = 1; intensity <= 100; intensity += 1) {
expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({
vibrancy: 'popover',
backgroundMaterial: 'tabbed'
})
}
})
})
describe('glassSupportedOn', () => {
it('is on for macOS regardless of kernel version', () => {
expect(glassSupportedOn('darwin')).toBe(true)
expect(glassSupportedOn('darwin', '24.6.0')).toBe(true)
})
it('is on for Windows 11 22H2 and newer, off for everything older', () => {
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true)
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true)
expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false)
expect(glassSupportedOn('win32', '10.0.19045')).toBe(false)
expect(glassSupportedOn('win32', '10.0')).toBe(false)
expect(glassSupportedOn('win32', '')).toBe(false)
})
it('is off on Linux — Electron has no first-party desktop material there', () => {
expect(glassSupportedOn('linux', '6.8.0')).toBe(false)
})
})
describe('backgroundMaterialFor', () => {
it('is none while glass is off so DWM does not keep drawing under the backing', () => {
expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none')
expect(backgroundMaterialFor(clear(60))).toBe('none')
})
it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => {
expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic')
expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed')
expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica')
})
// Windows 11 has three system materials for four rungs, so the two heaviest
// land on mica. The mapping stays total — a saved 'header' still resolves —
// and the picker drops the duplicate instead (see glassMaterialsFor).
it('collapses Glare onto mica with Bright', () => {
expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica')
expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar')))
})
it('resolves every shipped rung to a real system material', () => {
for (const material of GLASS_MATERIALS) {
expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material)))
}
})
})
describe('translucencySupportedOn', () => {
it('covers the two platforms where setOpacity or a native material exists', () => {
expect(translucencySupportedOn('darwin')).toBe(true)
expect(translucencySupportedOn('win32')).toBe(true)
})
// Electron documents setOpacity as doing nothing on Linux, and there is no
// material either — so the setting has no working half to offer there.
it('is off on Linux, where neither mode does anything', () => {
expect(translucencySupportedOn('linux')).toBe(false)
expect(translucencySupportedOn('freebsd')).toBe(false)
})
// Win10 loses glass but keeps clear, so the row must survive there.
it('stays on for a Windows build too old for glass', () => {
const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`
expect(glassSupportedOn('win32', oldWindows)).toBe(false)
expect(translucencySupportedOn('win32')).toBe(true)
})
})
describe('the frost rungs a platform offers', () => {
it('offers the whole ladder on macOS', () => {
expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS)
})
// The census rule, now enforced on Windows too: no two options in the picker
// may composite to the same thing. Bright and Glare are both mica.
it('never offers two rungs that render the same Windows backdrop', () => {
const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))
expect(new Set(backdrops).size).toBe(backdrops.length)
expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length)
})
it('keeps every distinct Windows backdrop reachable from the picker', () => {
const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))))
const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material))))
expect(backdrops).toEqual(reachable)
})
// Settings synced from a Mac carry a rung Windows has no button for. The
// picker highlights the button that renders the same backdrop rather than
// showing nothing selected — and does NOT rewrite what the Mac saved.
it('folds a dropped rung onto the button that looks the same', () => {
expect(glassMaterialForPicker('header', true)).toBe('titlebar')
expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true))
expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe(
backgroundMaterialFor(glass(60, 'header'))
)
})
it('leaves every rung alone on macOS and every offered rung alone on Windows', () => {
for (const material of GLASS_MATERIALS) {
expect(glassMaterialForPicker(material, false)).toBe(material)
}
for (const material of glassMaterialsFor(true)) {
expect(glassMaterialForPicker(material, true)).toBe(material)
}
})
})
describe('normalizeState', () => {
it('parses a modern payload', () => {
expect(normalizeState({ intensity: 40, mode: 'glass', material: 'header', scope: 'sidebar' }, true)).toEqual({
expect(
normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true)
).toEqual({
intensity: 40,
fade: 15,
mode: 'glass',
material: 'header',
scope: 'sidebar'
@@ -239,19 +423,28 @@ describe('normalizeState', () => {
it('keeps a legacy intensity-only payload on clear', () => {
expect(normalizeState({ intensity: 70 }, true)).toEqual({
intensity: 70,
fade: 0,
mode: 'clear',
material: DEFAULT_GLASS_MATERIAL,
scope: DEFAULT_GLASS_SCOPE
})
})
it('survives junk payloads', () => {
const base = { intensity: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }
// Fade arrived after glass shipped, so a profile written by the older build
// has no key for it and must come back unfaded rather than undefined.
it('defaults a payload written before fade existed to no fade', () => {
expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0)
})
// A fresh macOS profile lands on glass at zero intensity: selected, but off.
it('survives junk payloads', () => {
const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE }
// A fresh glass-capable profile lands on glass at zero intensity: selected, but off.
expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' })
expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' })
expect(normalizeState({ intensity: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)).toEqual({
expect(
normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false)
).toEqual({
...base,
mode: 'clear'
})
@@ -266,8 +459,8 @@ describe('glassActive', () => {
})
})
// The default must be selected-but-off: a fresh macOS profile shows Glass in
// the picker while the window itself is untouched until the lever moves.
// The default must be selected-but-off: a fresh glass-capable profile shows
// Glass in the picker while the window itself is untouched until the lever moves.
describe('a fresh profile', () => {
const fresh = normalizeState(null, true)
@@ -326,12 +519,22 @@ describe('what an update actually changes natively', () => {
expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true })
})
// The one glass drag that reaches main, and it costs what a clear drag costs.
it('is only the opacity while dragging fade under glass', () => {
expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({
backing: false,
material: false,
opacity: true
})
})
it('is the material alone when the frost level changes', () => {
expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({
backing: false,
material: true,
opacity: false
})
expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header')))
})
// Crossing zero flips glass on/off, which is exactly when the backing has to
+10 -1
View File
@@ -14,6 +14,7 @@
import { glassActive, type TranslucencyState } from '../../shared/src/translucency'
export {
backgroundMaterialFor,
clampIntensity,
DEFAULT_GLASS_MATERIAL,
DEFAULT_GLASS_SCOPE,
@@ -21,7 +22,11 @@ export {
GLASS_SCOPES,
glassActive,
type GlassMaterial,
glassMaterialForPicker,
glassMaterialsFor,
glassSupportedOn,
glassSurfaceKeep,
hudFrostFor,
normalizeMaterial,
normalizeMode,
normalizeScope,
@@ -31,8 +36,12 @@ export {
TRANSLUCENCY_MIN,
TRANSLUCENCY_OPACITY_FLOOR,
type TranslucencyState,
translucencySupportedOn,
vibrancyFor,
windowOpacityFor
windowOpacityFor,
WINDOWS_BACKGROUND_MATERIALS,
WINDOWS_GLASS_MIN_BUILD,
type WindowsBackgroundMaterial
} from '../../shared/src/translucency'
/**
@@ -5,9 +5,9 @@
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
// the real hermes.cmd/hermes.exe.
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
// signal), not just the hermes.exe console-script shim, or healthy
// installs get forced into a destructive --repair.
// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale
// install provenance. The bootstrap marker can outlive the venv, and a
// partial venv cannot run the updater; those states require --repair.
// 3. resolveVenvHermesCommand() — must probe the venv python via
// canImportHermesCli() before trusting it, or a broken venv gets
// re-selected forever instead of falling through to bootstrap.
@@ -45,17 +45,43 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
})
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => {
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [
'--update',
'--branch',
'main'
])
})
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => {
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'),
['--repair', '--branch', 'main']
)
})
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
test('chooseUpdaterArgs: partial updater runtimes use --repair', () => {
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [
'--repair',
'--branch',
'main'
])
assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [
'--repair',
'--branch',
'main'
])
})
test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => {
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'),
['--update', '--branch', 'release/1.2']
)
assert.deepEqual(
chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'),
['--repair', '--branch', 'release/1.2']
)
})
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
+21 -19
View File
@@ -11,12 +11,11 @@
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
* the desktop fell through to a spurious bootstrap/repair. The fix:
* PATHEXT extensions first, empty extension LAST.
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
* --update vs the destructive --repair by checking ONLY
* venv\Scripts\hermes.exe (the console-script shim, written at the END
* of venv setup and absent in interrupted states), so it escalated to a
* full venv recreate even on healthy installs. The fix: gate on ANY
* real-install signal, not just the shim.
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate
* install provenance from updater viability. A bootstrap-complete marker
* can outlive a deleted venv, while the updater needs BOTH the venv Python
* and Hermes launcher. Marker-only or partial runtimes must use --repair;
* only a runnable pair can use --update.
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
* the venv python with NO runtime probe (bypassing the caller's
* --version check too), so a venv broken mid-update (e.g. missing
@@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b
}
/**
* Choose the Windows bootstrap-recovery updater invocation: the gentle
* in-place --update when ANY real-install signal is present, the
* destructive --repair (full venv recreate) otherwise.
* Choose the Windows bootstrap-recovery invocation. The gentle in-place
* updater can only start when both pieces of its runtime contract exist: the
* venv Python interpreter and the Hermes launcher that drives `hermes update`.
* A bootstrap-complete marker proves install provenance, not current runtime
* usability, and may remain after the venv is removed or quarantined.
*
* haveRealInstall must be computed by the caller from ALL real-install
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
* marker) — gating on just the hermes.exe console-script shim alone is the
* regression this function's callers must avoid: that shim is written at
* the END of venv setup and is absent in exactly the interrupted/quarantined
* states this recovery exists to heal.
*
* @param {boolean} haveRealInstall
* @param {BootstrapRecoverySignals} signals
* @param {string} branch
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
*/
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
export interface BootstrapRecoverySignals {
hasBootstrapMarker: boolean
hasVenvHermes: boolean
hasVenvPython: boolean
}
export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] {
const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython
return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
}
/**
@@ -1,6 +1,6 @@
import crypto from 'node:crypto'
import { redactSecrets, SSH_ERROR } from './ssh-connection'
import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection'
const LOCKFILE_SCHEMA_VERSION = 2
const PROTOCOL_VERSION = 1
@@ -162,14 +162,6 @@ function reusableWindowsLock(lock, state, profile, reuseToken, runtime) {
)
}
function assertCurrent(signal) {
if (signal?.aborted) {
const error: any = new Error('SSH bootstrap was cancelled.')
error.kind = 'superseded'
throw error
}
}
async function processState(ssh, runtime, lock) {
return helper(ssh, runtime, 'process-state', [
String(lock.pid),
@@ -215,7 +207,7 @@ async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
let state
try {
@@ -286,7 +278,7 @@ async function connectWindowsRemote(deps) {
readyTimeoutMs = 45_000
} = deps
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
const runtime = await probeWindowsRemote(ssh, remoteHermesPath)
const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath])
@@ -356,7 +348,7 @@ async function connectWindowsRemote(deps) {
await helper(ssh, runtime, 'remove-lock', [ownershipId])
}
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
const token = crypto.randomBytes(32).toString('hex')
const spawnNonce = crypto.randomBytes(8).toString('hex')
await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token)
@@ -405,7 +397,7 @@ async function connectWindowsRemote(deps) {
await forward(localPort, remotePort)
const baseUrl = `http://127.0.0.1:${localPort}`
await waitForHermes(baseUrl, token)
assertCurrent(signal)
assertBootstrapNotSuperseded(signal)
await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort }))
return {
+3 -2
View File
@@ -113,13 +113,14 @@
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"blobatar": "0.2.0",
"blobatar": "2.0.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"d3-force": "3.0.0",
"dnd-core": "14.0.1",
"dompurify": "3.4.13",
"driver.js": "1.8.0",
"emojibase-data": "16.0.3",
"fflate": "0.8.3",
"frimousse": "0.3.0",
@@ -129,7 +130,7 @@
"katex": "0.16.47",
"mermaid": "11.16.1",
"motion": "12.42.2",
"nanostores": "1.4.0",
"nanostores": "1.4.2",
"node-pty": "1.1.0",
"radix-ui": "1.6.7",
"react": "19.2.7",
+46 -22
View File
@@ -437,7 +437,7 @@ const GET_WINDOWS_VERSION = '9.3.0'
export function stageGetWindowsInto(
srcRoot,
destRoot,
{ platform = process.platform, arch = process.arch, rebuild } = {}
{ platform = process.platform, arch = process.arch, install } = {}
) {
// The STAGED_WINDOWS_JS rewrite mirrors this exact version's export surface.
// A version bump must fail the build here until the rewrite is re-verified —
@@ -495,6 +495,7 @@ export function stageGetWindowsInto(
)
: []
let bindingDirs = scanBindingDirs()
let installAttempted = false
if (bindingDirs.length === 0 && arch === 'arm64') {
// get-windows 9.3.0 publishes win32 prebuilds for ia32/x64 only.
// The staged windows.js deliberately fails soft when binding/ is absent,
@@ -503,24 +504,25 @@ export function stageGetWindowsInto(
'[stage-native-deps] get-windows has no win32-arm64 prebuilt binding; ' +
'staging the fail-soft JS surface without native window enumeration.'
)
} else if (bindingDirs.length === 0 && typeof rebuild === 'function') {
} else if (bindingDirs.length === 0 && typeof install === 'function') {
// A plain `npm install` won't re-run an install script for a package
// that is already on disk, so every checkout that installed while
// get-windows was missing from allowScripts stays bricked even after
// the allowlist is fixed. `npm rebuild` re-runs it.
// the allowlist is fixed. Invoke node-pre-gyp directly: npm treats this
// optional dependency's failed lifecycle as non-fatal and can report a
// successful rebuild without producing the Windows binding.
console.log(
'[stage-native-deps] get-windows has no win32 binding; running `npm rebuild get-windows`...'
'[stage-native-deps] get-windows has no win32 binding; running its native installer...'
)
rebuild()
installAttempted = true
install()
bindingDirs = scanBindingDirs()
}
if (bindingDirs.length === 0 && arch !== 'arm64') {
throw new Error(
`[stage-native-deps] get-windows has no win32-${arch} prebuilt binding under lib/binding. ` +
'Recover from the checkout root with:\n' +
' npm install-scripts approve get-windows\n' +
' npm rebuild get-windows'
)
const reason = installAttempted
? `native installer completed without producing a win32-${arch} binding under lib/binding`
: `has no win32-${arch} prebuilt binding under lib/binding`
throw new Error(`[stage-native-deps] get-windows ${reason}`)
}
for (const dir of bindingDirs) {
const dest = join(destRoot, 'lib', 'binding', dir)
@@ -542,15 +544,35 @@ export function stageGetWindowsInto(
return destRoot
}
function rebuildGetWindowsViaNpm() {
const result = spawnSync('npm', ['rebuild', 'get-windows'], {
cwd: resolve(projectRoot, '..', '..'),
stdio: 'inherit',
// npm resolves to npm.cmd on Windows, which needs a shell.
shell: process.platform === 'win32'
export function installGetWindowsNativeBinding(
srcRoot,
{ resolveInstaller, spawn = spawnSync } = {}
) {
let installerPath
try {
const resolveNodePreGyp =
resolveInstaller ??
(() =>
require.resolve('@mapbox/node-pre-gyp/bin/node-pre-gyp', {
paths: [srcRoot]
}))
installerPath = resolveNodePreGyp()
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`[stage-native-deps] cannot resolve get-windows native installer: ${detail}`)
}
const result = spawn(process.execPath, [installerPath, 'install', '--fallback-to-build'], {
cwd: srcRoot,
stdio: 'inherit'
})
if (result.error) {
throw new Error(
`[stage-native-deps] get-windows native installer could not start: ${result.error.message}`
)
}
if (result.status !== 0) {
console.warn(`[stage-native-deps] npm rebuild get-windows exited with ${result.status}`)
throw new Error(`[stage-native-deps] get-windows native installer exited with ${result.status}`)
}
}
@@ -585,10 +607,12 @@ export function stageGetWindows(
}
// Only a win32 host can produce the win32 binding, so a cross-platform pack
// has nothing to gain from the rebuild.
const rebuild =
platform === 'win32' && process.platform === 'win32' ? rebuildGetWindowsViaNpm : undefined
return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, rebuild })
// has nothing to gain from the native installer.
const install =
platform === 'win32' && process.platform === 'win32'
? () => installGetWindowsNativeBinding(srcRoot)
: undefined
return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, install })
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
installGetWindowsNativeBinding,
stageGetWindows,
stageGetWindowsInto,
stageNodePtyInto,
@@ -460,7 +461,7 @@ test('win32-arm64 staging omits incompatible bindings and keeps the fail-soft JS
}
})
test('win32 staging self-heals through the rebuild hook when the binding is missing', () => {
test('win32 staging self-heals through the native installer when the binding is missing', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
@@ -471,7 +472,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss
makeFakeGetWindows(srcRoot, { bindings: [] })
let calls = 0
const rebuild = () => {
const install = () => {
calls += 1
makeFakeNode(
join(srcRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'),
@@ -479,7 +480,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss
)
}
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', rebuild })
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', install })
assert.equal(calls, 1)
assert.ok(
@@ -490,7 +491,7 @@ test('win32 staging self-heals through the rebuild hook when the binding is miss
}
})
test('win32 staging reports the recovery steps when the rebuild hook produces nothing', () => {
test('win32 staging rejects a successful installer that produces no binding', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
@@ -503,15 +504,69 @@ test('win32 staging reports the recovery steps when the rebuild hook produces no
stageGetWindowsInto(srcRoot, destRoot, {
platform: 'win32',
arch: 'x64',
rebuild: () => {}
install: () => {}
}),
/npm rebuild get-windows/
(error) => {
assert.match(error.message, /installer completed without producing a win32-x64 binding/)
assert.doesNotMatch(error.message, /npm rebuild/)
return true
}
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('get-windows native install invokes node-pre-gyp directly from the package root', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const installer = join(
srcRoot,
'node_modules',
'@mapbox',
'node-pre-gyp',
'bin',
'node-pre-gyp'
)
fs.mkdirSync(path.dirname(installer), { recursive: true })
fs.writeFileSync(
join(srcRoot, 'node_modules', '@mapbox', 'node-pre-gyp', 'package.json'),
JSON.stringify({ name: '@mapbox/node-pre-gyp', version: '1.0.11' })
)
fs.writeFileSync(installer, '')
const calls = []
installGetWindowsNativeBinding(srcRoot, {
spawn: (command, args, options) => {
calls.push({ command, args, options })
return { status: 0 }
}
})
assert.deepEqual(calls, [
{
command: process.execPath,
args: [fs.realpathSync(installer), 'install', '--fallback-to-build'],
options: { cwd: srcRoot, stdio: 'inherit' }
}
])
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('get-windows native install surfaces node-pre-gyp failure', () => {
assert.throws(
() =>
installGetWindowsNativeBinding('C:\\fake\\get-windows', {
resolveInstaller: () => 'C:\\fake\\node-pre-gyp',
spawn: () => ({ status: 1 })
}),
/native installer exited with 1/
)
})
test('staging refuses a get-windows version the lib/windows.js rewrite was not verified against', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
+154
View File
@@ -0,0 +1,154 @@
import { JsonRpcGatewayClient } from '@hermes/shared'
import type { HermesApiRequest } from '@/global'
// Desktop startup fires a burst of read-only data calls (config, profiles,
// model info/options, cron) the moment the backend passes readiness. On a
// profile-heavy or remote install these can each take tens of seconds — e.g.
// /api/profiles runs list_profiles(), which does a recursive skill-tree walk
// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts)
// times out a backend that is alive-but-busy, surfacing as a spurious
// "Timed out connecting to Hermes backend" that hangs the UI (#48504).
//
// Give the boot burst a generous per-call timeout instead of raising the
// global default: interactive/runtime calls and the liveness poll (/api/status)
// keep the short default so a genuinely-dead backend is still detected fast.
export const STARTUP_REQUEST_TIMEOUT_MS = 60_000
const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000
// prompt.submit is effectively fire-and-forget: turn completion is signaled by
// stream / message.complete events, NOT by the RPC return. A long turn (MoA
// presets running references + aggregator in series, deep reasoning, large tool
// chains) can legitimately take minutes to ACK, so bounding the ack by the
// generic 30s default surfaces a false "request timed out" toast while the turn
// is still running and will succeed (issue #55024). Match the backend's
// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only
// ever fires when the turn itself would have been abandoned server-side.
export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000
export class HermesGateway extends JsonRpcGatewayClient {
constructor() {
super({
closedErrorMessage: 'Hermes gateway connection closed',
connectErrorMessage: 'Could not connect to Hermes gateway',
createRequestId: nextId => nextId,
notConnectedErrorMessage: 'Hermes gateway is not connected',
requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS
})
}
}
// Profile that profile-scoped REST settings (config/env/skills/tools/model/…)
// should target. Mirrors $activeGatewayProfile, pushed in from the store via
// setApiRequestProfile so this module needs no store import (avoids a cycle).
// Electron main consumes request.profile as request scope. Local calls whose
// REST handlers accept profile reuse the primary dashboard via ?profile=;
// unscoped handlers retain a profile backend. Remote overrides still route to
// their owning backend. Null → primary, so single-profile users are unaffected.
let _apiProfile: null | string = null
export function setApiRequestProfile(profile: null | string): void {
_apiProfile = profile || null
}
export function profileScoped(profile?: null | string): { profile?: string } {
const selected = profile === undefined ? _apiProfile : profile
return selected ? { profile: selected } : {}
}
/** Profile that profile-scoped REST/WS calls should target (null → primary).
* Read-only twin of setApiRequestProfile for modules (e.g. voice playback)
* that build their own connection URLs and must stay on the same backend. */
export function getApiRequestProfile(): null | string {
return _apiProfile
}
// Registry connection serving the active gateway (null → the local pool).
// Pushed from store/gateway's setActive — the single seam BOTH
// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls
// that dial their own backend (pluginSocket) resolve it through the SAME
// source of truth those paths maintain for $connection. That makes the plugin
// socket follow registry-agent activations too, not just profile switches.
// Same no-store-import contract as _apiProfile (avoids a cycle).
let _apiConnectionId: null | string = null
export function setApiRequestConnection(connectionId: null | string): void {
_apiConnectionId = connectionId || null
}
// Registry connection scope for a REST request. A registered remote gateway
// owns its own state.db — cron jobs and their run sessions live THERE — so
// requests for gateway-owned data must carry the connection id for the main
// process to route them to that host (hermes:api's registry branch). Null
// resolves to no tag, keeping single-source users byte-identical; explicit
// 'local' must remain tagged when the legacy primary points elsewhere.
export function connectionScoped(): { connectionId?: string } {
return _apiConnectionId ? { connectionId: _apiConnectionId } : {}
}
/** Send a REST request to the renderer's active registry source. Request-level
* routing may override the active source for an explicitly-owned resource.
*
* Helpers under `api/` go through here rather than calling the preload bridge
* directly, so the connection tag cannot be forgotten on a new one — with one
* exception. A capabilityScoped() helper must NOT: that scope says "the local
* pool" by omitting `connectionId` entirely, and an absent key cannot override
* the ambient tag spread underneath it, so a 'local' pin would silently route
* to whatever remote gateway happened to be active. Those helpers call the
* bridge directly and own their routing end to end. */
export function hermesApi<T>(request: HermesApiRequest): Promise<T> {
return window.hermesDesktop.api<T>({ ...connectionScoped(), ...request })
}
// ── Capability scope: (connection, profile) routing for the Capabilities
// surface (skills / toolsets / MCP / hub / env / toolset config) ────────────
//
// A profile is not a machine-global name — it belongs to ONE gateway. The
// Capabilities surface can be pointed at any (connection, profile) pair
// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so
// its REST helpers accept either the legacy string form or an explicit scope
// object:
//
// - `undefined` / string → the legacy profile path, PLUS the active registry
// connection tag (connectionScoped, same contract the cron helpers adopted
// in #87882). Without the tag, a window activated onto a registered remote
// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine.
// - `{ connectionId, profile }` → explicit pin. `''`/`'local'` connection
// ids mean the local pool and deliberately DROP the ambient connection
// tag, so a local-profile pick made while a remote gateway is active still
// routes to the local machine.
export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string }
export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } {
if (scope && typeof scope === 'object') {
const profile = (scope.profile ?? '').trim()
const connectionId = (scope.connectionId ?? '').trim()
return {
...(profile ? { profile } : {}),
...(connectionId && connectionId !== 'local' ? { connectionId } : {})
}
}
return { ...profileScoped(scope), ...connectionScoped() }
}
/** Stable cache-key for a capability scope: `profile` for the local/legacy
* path, `connectionId::profile` for an explicit remote pin. Mirrors
* normalizeProfileKey for plain strings so existing keys stay byte-identical. */
export function profileScopeKey(scope?: ProfileScope): string {
if (scope && typeof scope === 'object') {
const profile = (scope.profile ?? '').trim() || 'default'
const connectionId = (scope.connectionId ?? '').trim()
return connectionId && connectionId !== 'local' ? `${connectionId}::${profile}` : profile
}
return (scope ?? '').trim() || 'default'
}
/** Registry connection id that connection-scoped WS calls should target
* (null → the local pool). Read-only twin of setApiRequestConnection. */
export function getApiRequestConnection(): null | string {
return _apiConnectionId
}
+239
View File
@@ -0,0 +1,239 @@
import type {
ConfigSchemaResponse,
CustomEndpointsResponse,
CustomEndpointUpdate,
CustomEndpointValidationResponse,
EnvVarInfo,
HermesConfig,
HermesConfigRecord,
LogsResponse,
OAuthPollResponse,
OAuthProvidersResponse,
OAuthStartResponse,
OAuthSubmitResponse,
StatusResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getStatus(): Promise<StatusResponse> {
return hermesApi<StatusResponse>({
...profileScoped(),
path: '/api/status'
})
}
export function getLogs(params: {
component?: string
file?: string
level?: string
lines?: number
search?: string
}): Promise<LogsResponse> {
const query = new URLSearchParams()
if (params.file) {
query.set('file', params.file)
}
if (typeof params.lines === 'number') {
query.set('lines', String(params.lines))
}
if (params.level && params.level !== 'ALL') {
query.set('level', params.level)
}
if (params.component && params.component !== 'all') {
query.set('component', params.component)
}
if (params.search) {
query.set('search', params.search)
}
const suffix = query.toString()
return hermesApi<LogsResponse>({
...profileScoped(),
path: suffix ? `/api/logs?${suffix}` : '/api/logs'
})
}
export function getHermesConfig(profile?: string): Promise<HermesConfig> {
return hermesApi<HermesConfig>({
...profileScoped(profile),
path: '/api/config',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigRecord(profile?: ProfileScope): Promise<HermesConfigRecord> {
return window.hermesDesktop.api<HermesConfigRecord>({
...capabilityScoped(profile),
path: '/api/config'
})
}
export function getHermesConfigDefaults(): Promise<HermesConfigRecord> {
return hermesApi<HermesConfigRecord>({
...profileScoped(),
path: '/api/config/defaults',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getHermesConfigSchema(profile?: null | string): Promise<ConfigSchemaResponse> {
return hermesApi<ConfigSchemaResponse>({
...profileScoped(profile),
path: '/api/config/schema'
})
}
export function saveHermesConfig(config: HermesConfigRecord, profile?: null | string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: '/api/config',
method: 'PUT',
body: { config }
})
}
export function getEnvVars(profile?: null | string): Promise<Record<string, EnvVarInfo>> {
return hermesApi<Record<string, EnvVarInfo>>({
...profileScoped(profile),
path: '/api/env'
})
}
export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/env',
method: 'PUT',
body: { key, value }
})
}
export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/env',
method: 'DELETE',
body: { key }
})
}
export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> {
return window.hermesDesktop.api<{ key: string; value: string }>({
...capabilityScoped(profile),
path: '/api/env/reveal',
method: 'POST',
body: { key }
})
}
export function validateProviderCredential(
key: string,
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> {
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
})
}
export function getCustomEndpoints(): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: '/api/providers/custom-endpoints'
})
}
export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: '/api/providers/custom-endpoints',
method: 'POST',
body: endpoint
})
}
export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise<CustomEndpointValidationResponse> {
return hermesApi<CustomEndpointValidationResponse>({
path: '/api/providers/custom-endpoints/validate',
method: 'POST',
body: endpoint
})
}
export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> {
return hermesApi<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`,
method: 'POST'
})
}
export function deleteCustomEndpoint(id: string): Promise<CustomEndpointsResponse> {
return hermesApi<CustomEndpointsResponse>({
...profileScoped(),
path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
return hermesApi<OAuthProvidersResponse>({
...profileScoped(),
path: '/api/providers/oauth'
})
}
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
return hermesApi<{ ok: boolean; provider: string }>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
method: 'DELETE'
})
}
export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise<OAuthStartResponse> {
return window.hermesDesktop.api<OAuthStartResponse>({
...capabilityScoped(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
method: 'POST',
body: {}
})
}
export function submitOAuthCode(providerId: string, sessionId: string, code: string): Promise<OAuthSubmitResponse> {
return hermesApi<OAuthSubmitResponse>({
...profileScoped(),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
method: 'POST',
body: { session_id: sessionId, code }
})
}
export function pollOAuthSession(
providerId: string,
sessionId: string,
profile?: ProfileScope
): Promise<OAuthPollResponse> {
return window.hermesDesktop.api<OAuthPollResponse>({
...capabilityScoped(profile),
path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`
})
}
export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
method: 'DELETE'
})
}
+153
View File
@@ -0,0 +1,153 @@
import type {
AutomationBlueprint,
CronDeliveryTarget,
CronJob,
CronJobCreatePayload,
CronJobUpdates,
SessionInfo
} from '@/types/hermes'
import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
// The cron trigger endpoint intentionally waits for the whole job so its
// response reflects the persisted execution result. Agent jobs can run far
// longer than the Electron fetch default; keep this override local to the one
// synchronous long-operation endpoint rather than weakening all API timeouts.
const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
// backend's list endpoint defaults to 'all'. Pass a concrete profile key to
// list just that profile's jobs, or 'all' for the unified cross-profile view.
// Omitting the arg keeps the legacy 'all' default for non-profile callers.
// profileScoped() still rides along for backend-process routing.
export function getCronJobs(profile?: string): Promise<CronJob[]> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return hermesApi<CronJob[]>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
})
}
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
const { runs } = await hermesApi<{ runs: SessionInfo[] }>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
})
return runs ?? []
}
// The single source of truth for cron delivery targets (local + configured
// gateways). Both the manual cron editor and the blueprint dialog use this so
// they never offer a platform that isn't connected. Mirrors the dashboard.
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/delivery-targets'
})
return targets ?? []
}
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/jobs',
method: 'POST',
body
})
}
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'PUT',
body: { updates }
})
}
export function pauseCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
method: 'POST'
})
}
export function resumeCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
method: 'POST'
})
}
export function triggerCronJob(jobId: string): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
method: 'POST',
timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS
})
}
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
method: 'DELETE'
})
}
// Automation Blueprints — parameterized cron templates the backend serves from
// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery
// (deliver options already rewritten to this machine's configured gateways);
// instantiateAutomationBlueprint fills the slots and creates a real cron job via
// the same create_job path as createCronJob.
//
// Profile-scoping is intentionally asymmetric: the GET catalog is global (the
// list endpoint takes no profile — only deliver options are rewritten from the
// configured gateways), so it carries only the profileScoped() header for
// routing. instantiate creates a real per-profile job, so it names the target
// profile explicitly via ?profile=. This mirrors the dashboard's api.ts.
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
return hermesApi<{ blueprints: AutomationBlueprint[] }>({
...profileScoped(),
...connectionScoped(),
path: '/api/cron/blueprints',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function instantiateAutomationBlueprint(
body: { blueprint: string; values: Record<string, string> },
profile: string
): Promise<CronJob> {
return hermesApi<CronJob>({
...profileScoped(),
...connectionScoped(),
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
method: 'POST',
body
})
}
+147
View File
@@ -0,0 +1,147 @@
import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
export interface McpTestResult {
ok: boolean
error?: string
/** `schema_chars` (converted registry-schema size, chars) is additive —
* older backends omit it and the cost overlay shows no token estimate. */
tools: { name: string; description: string; schema_chars?: number }[]
/** Capability counts (absent on older backends / failed probes). */
prompts?: number
resources?: number
}
export interface McpOAuthFlow {
flow_id: string
server_name: string
status: 'starting' | 'authorization_required' | 'approved' | 'error'
authorization_url: string | null
error: string | null
tools?: { name: string; description: string }[]
}
/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
* for real) — well past the 15s default fetch timeout. */
export function testMcpServer(name: string, profile?: ProfileScope): Promise<McpTestResult> {
return window.hermesDesktop.api<McpTestResult>({
...capabilityScoped(profile),
path: `/api/mcp/servers/${encodeURIComponent(name)}/test`,
method: 'POST',
timeoutMs: 60_000
})
}
/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike
* `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes,
* re-enables (dropping `enabled: false`), and removed nested fields persist. */
export function saveMcpServers(
servers: Record<string, Record<string, unknown>>,
profile?: ProfileScope
): Promise<{ ok: boolean }> {
return window.hermesDesktop.api<{ ok: boolean }>({
...capabilityScoped(profile),
path: '/api/mcp/servers',
method: 'PUT',
body: { servers }
})
}
/** Start an MCP OAuth flow and return the authorization URL. */
export function authMcpServer(name: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...capabilityScoped(profile),
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
method: 'POST',
timeoutMs: 60_000
})
}
export function getMcpOAuthFlow(flowId: string, profile?: ProfileScope): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...capabilityScoped(profile),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`
})
}
/** Cancel an in-flight MCP OAuth flow server-side, freeing the per-server
* "already in progress" slot so a retry doesn't 409. */
export function cancelMcpOAuthFlow(flowId: string, profile?: null | string): Promise<{ ok: boolean; status: string }> {
return hermesApi<{ ok: boolean; status: string }>({
...profileScoped(profile),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`,
method: 'DELETE'
})
}
// ---------------------------------------------------------------------------
// MCP servers — structured list / test / enable toggle / catalog (parity with
// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in
// config.yaml via saveHermesConfig.
// ---------------------------------------------------------------------------
export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> {
return hermesApi<{ servers: McpServerSummary[] }>({
...profileScoped(),
path: '/api/mcp/servers'
})
}
/** Add one server to `mcp_servers` (validated + name-collision-checked
* server-side — the same endpoint the dashboard's add form uses). */
export function addMcpServer(body: {
name: string
url?: string
command?: string
args?: string[]
env?: Record<string, string>
auth?: string
}): Promise<McpServerSummary> {
return hermesApi<McpServerSummary>({
...profileScoped(),
path: '/api/mcp/servers',
method: 'POST',
body
})
}
/** Remove one server from `mcp_servers` (the inline setup card's rollback
* when a directory install is cancelled after the config write). */
export function removeMcpServer(name: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
export function getMcpCatalog(profile?: ProfileScope): Promise<McpCatalogResponse> {
return window.hermesDesktop.api<McpCatalogResponse>({
...capabilityScoped(profile),
path: '/api/mcp/catalog'
})
}
export function installMcpCatalogEntry(
name: string,
env: Record<string, string> = {},
profile?: ProfileScope
): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({
...capabilityScoped(profile),
path: '/api/mcp/catalog/install',
method: 'POST',
body: { name, env, enable: true },
timeoutMs: 60_000
})
}
+131
View File
@@ -0,0 +1,131 @@
import type {
MessagingPlatformsResponse,
MessagingPlatformTestResponse,
MessagingPlatformUpdate,
PairingResponse,
PairingUser,
WebhookCreatePayload,
WebhookCreateResponse,
WebhookEnableResponse,
WebhooksResponse
} from '@/types/hermes'
import { hermesApi, profileScoped } from './client'
export function getMessagingPlatforms(profile?: null | string): Promise<MessagingPlatformsResponse> {
return hermesApi<MessagingPlatformsResponse>({
...profileScoped(profile),
path: '/api/messaging/platforms'
})
}
export function updateMessagingPlatform(
platformId: string,
body: MessagingPlatformUpdate,
profile?: null | string
): Promise<{ ok: boolean; platform: string }> {
return hermesApi<{ ok: boolean; platform: string }>({
...profileScoped(profile),
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`,
method: 'PUT',
body
})
}
export function testMessagingPlatform(
platformId: string,
profile?: null | string
): Promise<MessagingPlatformTestResponse> {
return hermesApi<MessagingPlatformTestResponse>({
...profileScoped(profile),
path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`,
method: 'POST'
})
}
// -- Pairing (who may DM the bot) --------------------------------------------
// Unknown DMers get a one-time code and land in `pending` until an admin
// approves them. Approval grants on the row's `request_id`, never on the code:
// the code is the requester's proof that the channel is theirs and is never
// returned by the API, while an authenticated admin is only ever identifying
// a row they can already see.
export function getPairing(profile?: null | string): Promise<PairingResponse> {
return hermesApi<PairingResponse>({
...profileScoped(profile),
path: '/api/pairing'
})
}
export function approvePairing(
platform: string,
requestId: string,
profile?: null | string
): Promise<{ ok: boolean; user: PairingUser }> {
return hermesApi<{ ok: boolean; user: PairingUser }>({
...profileScoped(profile),
path: '/api/pairing/approve',
method: 'POST',
// These endpoints read the profile off the body, not the query string —
// `profileScoped()` alone would approve into the wrong profile's store.
body: { platform, request_id: requestId, ...profileScoped(profile) }
})
}
export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: '/api/pairing/revoke',
method: 'POST',
body: { platform, user_id: userId, ...profileScoped(profile) }
})
}
// -- Webhooks (subscription CRUD) --------------------------------------------
// The webhook receiver is its own gateway platform; subscriptions live in a
// shared JSON store the CLI/dashboard also drive. Enable mutates config and
// best-effort restarts the gateway; subscription changes hot-reload.
export function getWebhooks(): Promise<WebhooksResponse> {
return hermesApi<WebhooksResponse>({
...profileScoped(),
path: '/api/webhooks'
})
}
export function enableWebhooks(): Promise<WebhookEnableResponse> {
return hermesApi<WebhookEnableResponse>({
...profileScoped(),
path: '/api/webhooks/enable',
method: 'POST'
})
}
export function createWebhook(body: WebhookCreatePayload): Promise<WebhookCreateResponse> {
return hermesApi<WebhookCreateResponse>({
...profileScoped(),
path: '/api/webhooks',
method: 'POST',
body
})
}
export function deleteWebhook(name: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function setWebhookEnabled(
name: string,
enabled: boolean
): Promise<{ enabled: boolean; name: string; ok: boolean }> {
return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({
...profileScoped(),
path: `/api/webhooks/${encodeURIComponent(name)}/enabled`,
method: 'PUT',
body: { enabled }
})
}
+129
View File
@@ -0,0 +1,129 @@
import type {
AnalyticsResponse,
AuxiliaryModelsResponse,
MoaConfigResponse,
ModelAssignmentRequest,
ModelAssignmentResponse,
ModelInfoResponse,
ModelOptionsResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
return hermesApi<ModelInfoResponse>({
...profileScoped(profile),
path: '/api/model/info',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise<AnalyticsResponse> {
return window.hermesDesktop.api<AnalyticsResponse>({
...capabilityScoped(profile),
path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}`
})
}
export function getGlobalModelOptions(
opts?: {
refresh?: boolean
includeUnconfigured?: boolean
explicitOnly?: boolean
},
profile?: null | string
): Promise<ModelOptionsResponse> {
const params = new URLSearchParams()
if (opts?.refresh) {
params.set('refresh', '1')
}
if (opts?.includeUnconfigured) {
params.set('include_unconfigured', '1')
}
if (opts?.explicitOnly !== false) {
params.set('explicit_only', '1')
}
return hermesApi<ModelOptionsResponse>({
...profileScoped(profile),
path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export interface RecommendedDefaultModel {
provider: string
model: string
/** True/false for Nous (free vs paid tier); null for other providers. */
free_tier: boolean | null
}
// Recommended default model for a freshly-authenticated provider. Mirrors the
// curation `hermes model` does — for Nous it honors the free/paid tier so a
// free user gets a free model instead of a paid default.
export function getRecommendedDefaultModel(
provider: string,
profile?: null | string
): Promise<RecommendedDefaultModel> {
return hermesApi<RecommendedDefaultModel>({
...profileScoped(profile),
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
})
}
export function setGlobalModel(
provider: string,
model: string
): Promise<{ ok: boolean; provider: string; model: string }> {
return hermesApi<{ ok: boolean; provider: string; model: string }>({
...profileScoped(),
path: '/api/model/set',
method: 'POST',
body: {
scope: 'main',
provider,
model
}
})
}
export function getAuxiliaryModels(profile?: null | string): Promise<AuxiliaryModelsResponse> {
return hermesApi<AuxiliaryModelsResponse>({
...profileScoped(profile),
path: '/api/model/auxiliary'
})
}
export function getMoaModels(profile?: null | string): Promise<MoaConfigResponse> {
return hermesApi<MoaConfigResponse>({
...profileScoped(profile),
path: '/api/model/moa'
})
}
export function saveMoaModels(
body: MoaConfigResponse,
profile?: null | string
): Promise<MoaConfigResponse & { ok: boolean }> {
return hermesApi<MoaConfigResponse & { ok: boolean }>({
...profileScoped(profile),
path: '/api/model/moa',
method: 'PUT',
body
})
}
export function setModelAssignment(
body: ModelAssignmentRequest,
profile?: null | string
): Promise<ModelAssignmentResponse> {
return hermesApi<ModelAssignmentResponse>({
...profileScoped(profile),
path: '/api/model/set',
method: 'POST',
body
})
}
+128
View File
@@ -0,0 +1,128 @@
import type { HermesConnection } from '@/global'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client'
/** Resolve the ACTIVE backend's connection descriptor, (connectionId,
* profile)-scoped — mirroring how store/profile resolves $connection: a
* registry agent's descriptor comes from getConnectionFor (its SOURCE
* connection), everything else from the profile-keyed local pool. The
* getConnectionFor bridge is optional (older Desktop mains); without it the
* profile-scoped pool lookup is the best available answer. */
async function activeConnection(): Promise<HermesConnection> {
const getConnectionFor = window.hermesDesktop.getConnectionFor
const connectionId = getApiRequestConnection()
if (connectionId && getConnectionFor) {
return getConnectionFor({ connectionId, profile: getApiRequestProfile() })
}
return window.hermesDesktop.getConnection(getApiRequestProfile())
}
/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api`
* shape, minus the path (which is namespace-derived). */
export interface PluginRestOptions {
method?: string
body?: unknown
/** Single-file multipart upload (see HermesApiRequest.upload). */
upload?: { filename: string; contentType?: string; bytes: ArrayBuffer }
timeoutMs?: number
}
// Normalize `path` to a leading-slash suffix relative to `/api/plugins/<id>`.
// The namespace is the boundary — reject `..` so a relative segment can't
// normalize out into another plugin's API or a core route. Check the path
// portion only (before any query/hash).
function pluginPathSuffix(caller: string, path: string): string {
const suffix = path.startsWith('/') ? path : `/${path}`
if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) {
throw new Error(`${caller}: illegal path traversal in "${path}"`)
}
return suffix
}
/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's
* own backend namespace — `path` is relative to `/api/plugins/<pluginId>`
* ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another
* plugin's API or a core route through it. Profile-aware like every desktop
* REST call. Broader reach (core endpoints, another namespace) is the future
* declared-capability seam; today the namespace IS the boundary. */
export async function pluginRest<T>(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise<T> {
if (!window.hermesDesktop?.api) {
throw new Error('Hermes desktop bridge unavailable')
}
const suffix = pluginPathSuffix('pluginRest', path)
return hermesApi<T>({
path: `/api/plugins/${pluginId}${suffix}`,
method: opts.method,
body: opts.body,
upload: opts.upload,
timeoutMs: opts.timeoutMs,
...profileScoped()
})
}
/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same
* way: `path` is relative to `/api/plugins/<pluginId>` ('/events' → the
* plugin's own event stream). Token-mode backends auth via the same query
* credential the app's own sockets use; OAuth remotes resolve null (callers
* keep their polling fallback — every consumer must have one anyway, since a
* socket can drop). Auto-reconnects with backoff until disposed. */
export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void {
const suffix = pluginPathSuffix('pluginSocket', path)
let socket: null | WebSocket = null
let disposed = false
let attempt = 0
const connect = async () => {
const connection = await activeConnection().catch(() => null)
// No bridge / OAuth cookie auth (WS tickets are single-use, core-managed):
// stay on the polling fallback rather than half-working.
if (disposed || !connection || connection.authMode === 'oauth') {
return
}
const base = connection.baseUrl.replace(/^http/, 'ws')
const join = suffix.includes('?') ? '&' : '?'
socket = new WebSocket(
`${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}`
)
socket.onmessage = event => {
attempt = 0
try {
onMessage(JSON.parse(String(event.data)))
} catch {
// Non-JSON frame — plugin streams are JSON by contract; skip it.
}
}
socket.onclose = () => {
socket = null
if (!disposed) {
// Full-jitter exponential backoff: same rationale as the gateway
// socket reconnect loops — an immediate-retry loop across many
// desktop clients floods the gateway with connection attempts
// during a restart.
window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 }))
attempt += 1
}
}
}
void connect()
return () => {
disposed = true
socket?.close()
}
}
+89
View File
@@ -0,0 +1,89 @@
import type {
ProfileCreatePayload,
ProfileDesktopOverlay,
ProfileSetupCommand,
ProfileSoul,
ProfilesResponse
} from '@/types/hermes'
import { hermesApi, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getProfiles(): Promise<ProfilesResponse> {
return hermesApi<ProfilesResponse>({
path: '/api/profiles',
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
path: '/api/profiles',
method: 'POST',
body
})
}
export function renameProfile(name: string, newName: string): Promise<{ name: string; ok: boolean; path: string }> {
return hermesApi<{ name: string; ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'PATCH',
body: { new_name: newName }
})
}
export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> {
return hermesApi<{ ok: boolean; path: string }>({
path: `/api/profiles/${encodeURIComponent(name)}`,
method: 'DELETE'
})
}
export function getProfileSoul(name: string): Promise<ProfileSoul> {
return hermesApi<ProfileSoul>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`
})
}
export function updateProfileSoul(name: string, content: string): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/soul`,
method: 'PUT',
body: { content }
})
}
export function getProfileSetupCommand(name: string): Promise<ProfileSetupCommand> {
return hermesApi<ProfileSetupCommand>({
path: `/api/profiles/${encodeURIComponent(name)}/setup-command`
})
}
/** Export a profile to a shareable .tar.gz on the backend's filesystem.
* `extraFiles` stages extra root-level files (desktop.json — the appearance/
* interface overlay) into the archive alongside the profile's own artifacts. */
export function exportProfileArchive(
name: string,
opts: { extraFiles?: Record<string, string>; output?: string } = {}
): Promise<{ archive: string; ok: boolean }> {
return hermesApi<{ archive: string; ok: boolean }>({
path: `/api/profiles/${encodeURIComponent(name)}/export`,
method: 'POST',
body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
/** Import a profile .tar.gz as a new profile. Returns the bundled desktop
* appearance overlay too (when the archive carried one) so the caller can
* apply theme/layout without another round-trip. */
export function importProfileArchive(
archive: string,
name?: string
): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> {
return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({
path: '/api/profiles/import',
method: 'POST',
body: { archive, name: name || null },
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
+465
View File
@@ -0,0 +1,465 @@
import { isMissingRestEndpoint } from '@/lib/gateway-rpc'
import { recordTranscriptTail } from '@/store/transcript-tail'
import type {
PaginatedSessions,
SessionInfo,
SessionMessage,
SessionMessagesResponse,
SessionSearchResponse
} from '@/types/hermes'
import { hermesApi } from './client'
const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000
/**
* Trim a page to its window WITHOUT discarding pinned rows.
*
* The list endpoints deliberately back-fill pinned conversations past their
* LIMIT — a pin means "always reachable", so an aged-out pinned chat is
* appended after the recency window. A plain `slice(0, limit)` throws exactly
* those rows away again, which is why pins silently stopped rendering past
* some count: the sidebar could only ever show the pins that happened to fall
* inside the most-recent page.
*/
function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] {
if (sessions.length <= limit) {
return sessions
}
const recent = sessions.slice(0, limit)
return [...recent, ...sessions.slice(limit).filter(session => session.pinned)]
}
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
): Promise<PaginatedSessions> {
const result = await hermesApi<PaginatedSessions>({
path:
`/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
// Unified, read-only session list aggregated across ALL profiles. Served by the
// primary backend straight off each profile's state.db — no per-profile backend
// is spawned. Single-profile users get the same rows as listSessions(), tagged
// profile="default".
// Source scoping lets callers split the unified list into independent slices:
// recents pass `excludeSources: ['cron']`, the cron-jobs section passes
// `source: 'cron'`. Without this a burst of (always-newest) cron sessions
// consumes the whole recents page and starves real conversations.
export interface SessionSourceFilter {
source?: string
excludeSources?: string[]
}
export async function listAllProfileSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent',
profile: 'all' | (string & {}) = 'all',
filter: SessionSourceFilter = {}
): Promise<PaginatedSessions> {
const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : ''
const excludeParam = filter.excludeSources?.length
? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}`
: ''
const result = await hermesApi<PaginatedSessions>({
path:
`/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` +
`&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
return {
...result,
sessions: pageWindow(result.sessions, limit),
offset: 0
}
}
// Batched sidebar slices in one request: recents (scoped to the active profile),
// cron, and messaging. The backend opens each profile's state.db once and runs
// all three filtered queries, replacing three separate listAllProfileSessions
// calls that each reopened + re-counted every profile DB per refresh. Electron
// splices remote profiles per slice (see interceptSessionRequestForRemote).
export interface SidebarSessionSlice {
sessions: SessionInfo[]
/** Per-profile "the window came back full, more rows exist on disk" flags —
* what pagination needs, without a COUNT(*) per profile DB per refresh. */
profiles_truncated?: Record<string, boolean>
/** Per-profile tokens and spend over every session, not just this window.
* Absent from the legacy per-slice endpoint, which has no aggregate. */
profiles_usage?: Record<string, { cost_usd: number; tokens: number }>
}
/** Which profiles filled their per-profile window in a returned page. The
* legacy per-slice endpoint doesn't report this, so derive it from the rows:
* a profile at (or over) the cap still has more on disk. Pinned rows are
* discounted — they're back-filled past the LIMIT, so counting them fakes a
* full page and leaves a "Load more" that can never resolve. */
function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record<string, boolean> {
const counts = new Map<string, number>()
for (const session of sessions) {
const key = session.profile || 'default'
counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1))
}
return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap]))
}
export interface SidebarSessionsResponse {
recents: SidebarSessionSlice
cron: SidebarSessionSlice
messaging: SidebarSessionSlice
errors?: Array<{ profile: string; error: string }>
}
export interface SidebarSessionsRequest {
recentsProfile: 'all' | (string & {})
recentsLimit: number
recentsExclude: string[]
cronLimit: number
messagingLimit: number
messagingExclude: string[]
}
// The batched /sidebar endpoint shipped later than the per-slice route, so a
// newer desktop can meet an older backend that 404s it ("No such API
// endpoint"). Endpoint-missing is a capability signal, not a transient
// failure: remember it (per renderer lifetime — a runtime home change reloads
// the window and re-probes) and serve every subsequent refresh straight from
// the three proven per-slice calls instead of re-probing a known-dead route
// once per turn/broadcast.
let sidebarBatchEndpointMissing = false
// Capability flags are per-backend facts. A hard re-home reloads the window
// (module state resets naturally), but a soft gateway switch re-dials in
// place — the next backend may well have the batched route, so the switch
// paths call this to re-probe rather than leak the old backend's capability.
export function resetSidebarBatchCapability() {
sidebarBatchEndpointMissing = false
}
// Compatibility fallback: reassemble the three sidebar slices from the
// per-slice endpoint, mirroring the batched route's semantics (min_messages=1,
// archived excluded, recency order; every slice scoped to the caller's profile).
// Rides the same Electron remote-splice
// interception as the pre-batching desktop, so remote profiles stay correct.
async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
const [recents, cron, messaging] = await Promise.all([
listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.recentsExclude
}),
listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }),
listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, {
excludeSources: req.messagingExclude
})
])
const errors = [...(recents.errors ?? []), ...(cron.errors ?? []), ...(messaging.errors ?? [])]
return {
recents: {
profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit),
sessions: recents.sessions
},
cron: { sessions: cron.sessions },
messaging: { sessions: messaging.sessions },
...(errors.length ? { errors } : {})
}
}
/** The PR each of these sessions opened, recovered from its own transcript —
* for sessions whose recorded branch can't answer (they started on trunk and
* did the work in a worktree). Also returns every id it looked at, so the
* caller can remember a miss and never ask again. */
export function scanSessionPullRequests(
ids: string[]
): Promise<{ pull_requests: Record<string, { number: number; url: string }>; scanned: string[] }> {
return hermesApi<{
pull_requests: Record<string, { number: number; url: string }>
scanned: string[]
}>({
path: '/api/profiles/sessions/pull-requests',
method: 'POST',
body: { ids }
})
}
export async function listSidebarSessions(req: SidebarSessionsRequest): Promise<SidebarSessionsResponse> {
if (sidebarBatchEndpointMissing) {
return listSidebarSessionsLegacy(req)
}
const params = new URLSearchParams({
recents_profile: req.recentsProfile,
recents_limit: String(Math.max(1, req.recentsLimit)),
cron_limit: String(Math.max(1, req.cronLimit)),
messaging_limit: String(Math.max(1, req.messagingLimit))
})
if (req.recentsExclude.length) {
params.set('recents_exclude', req.recentsExclude.join(','))
}
if (req.messagingExclude.length) {
params.set('messaging_exclude', req.messagingExclude.join(','))
}
let result: SidebarSessionsResponse
try {
result = await hermesApi<SidebarSessionsResponse>({
path: `/api/profiles/sessions/sidebar?${params.toString()}`,
timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS
})
} catch (err) {
// Safe to read a 404 as route-missing here: this GET has no path params,
// so it cannot 404 on a bad id.
if (!isMissingRestEndpoint(err)) {
throw err
}
// Older backend without the batched route (desktop/runtime version skew).
sidebarBatchEndpointMissing = true
return listSidebarSessionsLegacy(req)
}
return {
recents: { ...result.recents, sessions: result.recents?.sessions ?? [] },
cron: { ...result.cron, sessions: result.cron?.sessions ?? [] },
messaging: { ...result.messaging, sessions: result.messaging?.sessions ?? [] },
errors: result.errors
}
}
// Mutations take the owning `profile` so Electron can route them to the correct
// remote backend or local profile scope. Omit for the current/default profile.
export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { archived }
})
}
// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive
// sweep (which runs backend-side, blind to Desktop localStorage) never hides a
// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own
// display; this only feeds the backend policy.
export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { pinned }
})
}
// Mirror a sidebar unread toggle to the backend read-state watermark
// (sessions.last_read_at via SessionDB.set_session_read). Same profile
// routing as the other session mutations: a remote session's row lives only
// on its remote host, so the owning profile must travel with the request.
export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { unread }
})
}
export function searchSessions(query: string): Promise<SessionSearchResponse> {
return hermesApi<SessionSearchResponse>({
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
})
}
// Resolves a single session row by id on one backend (the active profile, or
// the given `profile`). The backend resolves exact ids and unique prefixes and
// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the
// cross-profile list scan when locating an unknown id's owner.
export function getSession(id: string, profile?: string | null): Promise<SessionInfo> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''
return hermesApi<SessionInfo>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}${suffix}`
})
}
// Reads another profile's transcript. For a remote profile Electron reroutes
// this GET to the remote backend (which serves its own state.db); for a local
// profile the primary opens that profile's state.db via ?profile=. Omit for
// the current/default profile.
export function getSessionMessages(
id: string,
profile?: string | null,
page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}
): Promise<SessionMessagesResponse> {
const query = new URLSearchParams()
if (profile) {
query.set('profile', profile)
}
if (page.limit !== undefined) {
query.set('limit', String(page.limit))
}
if (page.offset !== undefined) {
query.set('offset', String(page.offset))
}
if (page.order) {
query.set('order', page.order)
}
if (page.includeCompacted !== undefined) {
query.set('include_compacted', String(page.includeCompacted))
}
const suffix = query.size ? `?${query.toString()}` : ''
return hermesApi<SessionMessagesResponse>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`
})
}
/**
* The initial hydration page: enough tail to fill the transcript window a few
* times over, small enough that opening a long session doesn't ship (and
* convert) hundreds of rows nobody has scrolled to. Older rows load on demand
* via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory
* store (see app/chat/transcript-backfill).
*/
export const LATEST_SESSION_MESSAGES_LIMIT = 120
export function getLatestSessionMessages(id: string, profile?: string | null): Promise<SessionMessagesResponse> {
// includeCompacted: durable display history must include rows preserved by
// in-place compaction (active=0, compacted=1); without them the transcript
// silently ends at the compaction boundary and earlier turns are unreachable.
return getSessionMessages(id, profile, {
limit: LATEST_SESSION_MESSAGES_LIMIT,
order: 'latest',
includeCompacted: true
}).then(page => {
// Record whether the tail was truncated (page came back full) and where
// the next older page starts, so "Show earlier" can backfill over REST
// (app/chat/transcript-backfill). Keyed under both the requested id and
// the resolved id — callers hold either.
recordTranscriptTail(id, page, profile)
if (page.session_id && page.session_id !== id) {
recordTranscriptTail(page.session_id, page, profile)
}
return page
})
}
/**
* One page of messages OLDER than the `offset` newest rows.
*
* Backend semantics (`_handle_session_messages` → `SessionDB.get_messages`
* with `latest=True`): the offset is measured back from the NEWEST message
* and the selected page is returned in chronological order. So after a tail
* hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the
* page immediately preceding it, ready to prepend.
*
* Legacy backends without pagination support return the full transcript and
* no `pagination` metadata — callers detect that via the missing field and
* treat the response as the complete history (see transcript-backfill).
*/
export function getOlderSessionMessages(
id: string,
profile: string | null | undefined,
offset: number,
limit: number = LATEST_SESSION_MESSAGES_LIMIT
): Promise<SessionMessagesResponse> {
return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' })
}
export async function getAllSessionMessages(
id: string,
profile?: string | null,
options: { maxJsonChars?: number } = {}
): Promise<SessionMessagesResponse> {
const messages: SessionMessage[] = []
const pageSize = 500
const maxJsonChars = options.maxJsonChars ?? 32_000_000
let jsonChars = 0
let offset = 0
let resolvedSessionId = id
while (true) {
const page = await getSessionMessages(id, profile, {
limit: pageSize,
offset,
order: 'oldest',
includeCompacted: true
})
resolvedSessionId = page.session_id
jsonChars += (JSON.stringify(page.messages) ?? '').length
if (jsonChars > maxJsonChars) {
throw new Error(
'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.'
)
}
messages.push(...page.messages)
// Legacy backends ignore pagination and return the full transcript.
if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) {
break
}
offset += page.messages.length
}
return { session_id: resolvedSessionId, messages }
}
export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'DELETE'
})
}
export function renameSession(
id: string,
title: string,
profile?: string | null
): Promise<{ ok: boolean; title: string }> {
return hermesApi<{ ok: boolean; title: string }>({
...(profile ? { profile } : {}),
path: `/api/sessions/${encodeURIComponent(id)}`,
method: 'PATCH',
body: { title, ...(profile ? { profile } : {}) }
})
}
+162
View File
@@ -0,0 +1,162 @@
import type {
SkillHubPreview,
SkillHubScanResult,
SkillHubSearchResponse,
SkillHubSourcesResponse,
SkillInfo,
StarmapGraph
} from '@/types/hermes'
import type { ActionResponse } from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
export function getSkills(profile?: ProfileScope): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...capabilityScoped(profile),
path: '/api/skills'
})
}
/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or
* learned — backing the Capabilities detail pane's full-skill view. */
export function getSkillContent(
name: string,
profile?: ProfileScope
): Promise<{ content: string; name: string; path: string }> {
return window.hermesDesktop.api<{ content: string; name: string; path: string }>({
...capabilityScoped(profile),
path: `/api/skills/content?name=${encodeURIComponent(name)}`
})
}
export function setSkillEnabled(
name: string,
enabled: boolean,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...capabilityScoped(profile),
path: '/api/skills/toggle',
method: 'PUT',
body: { name, enabled }
})
}
export function getStarmapGraph(): Promise<StarmapGraph> {
return hermesApi<StarmapGraph>({
...profileScoped(),
// Backend REST contract — stays /api/learning even though the UI feature is
// now "star map". Renaming this would break against an un-upgraded backend.
path: '/api/learning/graph'
})
}
export interface LearningNodeDetail {
content: string
kind: 'memory' | 'skill'
label: string
ok: boolean
}
export function getLearningNode(id: string, profile?: ProfileScope): Promise<LearningNodeDetail> {
return window.hermesDesktop.api<LearningNodeDetail>({
...capabilityScoped(profile),
path: `/api/learning/node?id=${encodeURIComponent(id)}`
})
}
export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...capabilityScoped(profile),
path: '/api/learning/node',
method: 'DELETE',
body: { id }
})
}
export function editLearningNode(
id: string,
content: string,
profile?: ProfileScope
): Promise<{ message: string; ok: boolean }> {
return window.hermesDesktop.api<{ message: string; ok: boolean }>({
...capabilityScoped(profile),
path: '/api/learning/node',
method: 'PUT',
body: { content, id }
})
}
// ---------------------------------------------------------------------------
// Skills hub — search / preview / scan / install (parity with `hermes skills`
// and the dashboard's Browse-hub tab). Installs spawn background actions whose
// logs are tailed via getActionStatus().
// ---------------------------------------------------------------------------
const HUB_REQUEST_TIMEOUT_MS = 45_000
export function getSkillHubSources(profile?: null | string): Promise<SkillHubSourcesResponse> {
return hermesApi<SkillHubSourcesResponse>({
...profileScoped(profile),
path: '/api/skills/hub/sources',
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function searchSkillsHub(
query: string,
source = 'all',
limit = 20,
profile?: null | string
): Promise<SkillHubSearchResponse> {
const params = new URLSearchParams({ q: query, source, limit: String(limit) })
return hermesApi<SkillHubSearchResponse>({
...profileScoped(profile),
path: `/api/skills/hub/search?${params.toString()}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function previewSkillHub(identifier: string, profile?: null | string): Promise<SkillHubPreview> {
return hermesApi<SkillHubPreview>({
...profileScoped(profile),
path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function scanSkillHub(identifier: string, profile?: null | string): Promise<SkillHubScanResult> {
return hermesApi<SkillHubScanResult>({
...profileScoped(profile),
path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`,
timeoutMs: HUB_REQUEST_TIMEOUT_MS
})
}
export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/install',
method: 'POST',
body: { identifier }
})
}
export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/uninstall',
method: 'POST',
body: { name }
})
}
export function updateSkillsFromHub(profile?: ProfileScope): Promise<ActionResponse> {
return window.hermesDesktop.api<ActionResponse>({
...capabilityScoped(profile),
path: '/api/skills/hub/update',
method: 'POST',
body: {}
})
}
+247
View File
@@ -0,0 +1,247 @@
import type {
ActionResponse,
ActionStatusResponse,
AudioSpeakResponse,
AudioTranscriptionResponse,
BackendUpdateCheckResponse,
CuratorStatusResponse,
DebugShareResponse,
ElevenLabsVoicesResponse,
MemoryProviderConfig,
MemoryProviderOAuthStatus,
MemoryStatusResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000
export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000
const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35
export function audioSpeakRequestTimeoutMs(text: string): number {
const estimated = Math.max(
AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,
Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)
)
return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)
}
export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000
export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000
// The transcribe payload is the base64 audio data URL itself, so its string
// length tracks clip size. ~0.1ms/char keeps short clips at the floor while
// letting multi-minute recordings scale toward the cap (a base64 char is
// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of
// timeout per 1s of audio before the cap clamps it).
const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1
export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {
const estimated = Math.max(
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)
)
return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)
}
// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema.
export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise<MemoryProviderConfig> {
return hermesApi<MemoryProviderConfig>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
export function saveMemoryProviderConfig(
provider: string,
values: Record<string, string>,
profile?: null | string
): Promise<{ ok: boolean }> {
return hermesApi<{ ok: boolean }>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }
})
}
// Memory-provider OAuth connect (provider-keyed; 404s for providers without an
// OAuth flow). Profile-scoped: the grant lands in the active profile's config.
export function startMemoryProviderOAuth(
provider: string,
profile?: null | string
): Promise<MemoryProviderOAuthStatus> {
return hermesApi<MemoryProviderOAuthStatus>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
method: 'POST'
})
}
export function getMemoryProviderOAuthStatus(
provider: string,
profile?: null | string
): Promise<MemoryProviderOAuthStatus> {
return hermesApi<MemoryProviderOAuthStatus>({
...profileScoped(profile),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
})
}
// ---------------------------------------------------------------------------
// Memory data + curator (parity with `hermes memory` / `hermes curator`).
// ---------------------------------------------------------------------------
export function getMemoryStatus(): Promise<MemoryStatusResponse> {
return hermesApi<MemoryStatusResponse>({
...profileScoped(),
path: '/api/memory'
})
}
export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> {
return hermesApi<{ ok: boolean; deleted: string[] }>({
...profileScoped(),
path: '/api/memory/reset',
method: 'POST',
body: { target }
})
}
export function getCuratorStatus(): Promise<CuratorStatusResponse> {
return hermesApi<CuratorStatusResponse>({
...profileScoped(),
path: '/api/curator'
})
}
export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> {
return hermesApi<{ ok: boolean; paused: boolean }>({
...profileScoped(),
path: '/api/curator/paused',
method: 'PUT',
body: { paused }
})
}
export function runCurator(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/curator/run',
method: 'POST',
body: {}
})
}
export function restartGateway(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/gateway/restart',
method: 'POST'
})
}
export function updateHermes(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/hermes/update',
method: 'POST'
})
}
/** Query the connected backend's own update state. In remote mode this is the
* authoritative source for the backend's behind-count + "what's changed",
* distinct from the Electron client clone's git state. */
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
return hermesApi<BackendUpdateCheckResponse>({
...profileScoped(),
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
})
}
export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise<ActionStatusResponse> {
return window.hermesDesktop.api<ActionStatusResponse>({
...capabilityScoped(profile),
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
})
}
export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<AudioTranscriptionResponse> {
return hermesApi<AudioTranscriptionResponse>({
path: '/api/audio/transcribe',
method: 'POST',
...profileScoped(),
body: {
data_url: dataUrl,
mime_type: mimeType
},
// Transcription blocks until provider STT, file handling, and response
// encoding finish. Remote providers and long clips regularly exceed the
// default 15s Electron backend timeout.
timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)
})
}
export function speakText(text: string): Promise<AudioSpeakResponse> {
return hermesApi<AudioSpeakResponse>({
...profileScoped(),
path: '/api/audio/speak',
method: 'POST',
body: { text },
// TTS blocks until provider synthesis, file read, and base64 encoding
// finish. Remote providers and large messages regularly exceed the
// default 15s Electron backend timeout.
timeoutMs: audioSpeakRequestTimeoutMs(text)
})
}
export function getElevenLabsVoices(profile?: null | string): Promise<ElevenLabsVoicesResponse> {
return hermesApi<ElevenLabsVoicesResponse>({
path: '/api/audio/elevenlabs/voices',
...profileScoped(profile)
})
}
/** `gh` CLI presence + auth state, for the composer's GitHub skill pill
* (GitHub is deliberately not an MCP — the github/* skills are the
* integration). Backend caches for 5 minutes; `refresh` bypasses. */
export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> {
return hermesApi<{ available: boolean; authenticated: boolean }>({
...profileScoped(),
path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}`
})
}
// ---------------------------------------------------------------------------
// Maintenance operations (parity with `hermes doctor` / `hermes security
// audit` / `hermes backup` / `hermes debug share` and the dashboard System
// page). All except debug share are spawn-based background actions tailed via
// getActionStatus().
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({ path: '/api/ops/doctor', method: 'POST', body: {} })
}
export function runSecurityAudit(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({ path: '/api/ops/security-audit', method: 'POST', body: {} })
}
export function runBackup(): Promise<ActionResponse & { archive?: string }> {
return hermesApi<ActionResponse & { archive?: string }>({
path: '/api/ops/backup',
method: 'POST',
body: {}
})
}
export function runDebugShare(): Promise<DebugShareResponse> {
return hermesApi<DebugShareResponse>({
path: '/api/ops/debug-share',
method: 'POST',
body: {},
// Synchronous upload of report + logs to the paste service.
timeoutMs: 120_000
})
}
+141
View File
@@ -0,0 +1,141 @@
import type {
ActionResponse,
ComputerUseStatus,
TerminalBackendsResponse,
ToolsetConfig,
ToolsetInfo,
ToolsetModelsResponse
} from '@/types/hermes'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client'
// The optional trailing `profile` on every capability fetcher below is the
// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP
// panels configure ANY profile without swapping the app-wide active profile.
// Omitting it (every pre-existing caller) means `profileScoped(undefined)`
// falls back to the app-wide `_apiProfile`, so behavior is byte-identical.
export function getToolsets(profile?: ProfileScope): Promise<ToolsetInfo[]> {
return window.hermesDesktop.api<ToolsetInfo[]>({
...capabilityScoped(profile),
path: '/api/tools/toolsets'
})
}
export function setToolsetEnabled(
name: string,
enabled: boolean,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
method: 'PUT',
body: { enabled }
})
}
export function getToolsetConfig(name: string, profile?: ProfileScope): Promise<ToolsetConfig> {
return window.hermesDesktop.api<ToolsetConfig>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
})
}
export function getToolsetModels(
name: string,
provider?: string,
profile?: ProfileScope
): Promise<ToolsetModelsResponse> {
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : ''
return window.hermesDesktop.api<ToolsetModelsResponse>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}`
})
}
export function selectToolsetModel(
name: string,
model: string,
provider?: string,
profile?: ProfileScope
): Promise<{ ok: boolean; name: string; model: string }> {
return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`,
method: 'PUT',
body: { model, provider }
})
}
export interface SelectToolsetProviderResponse {
ok: boolean
name: string
provider: string
/** Present when the selection was scoped to one web capability. */
capability?: string
/** Present (true) when a managed Nous row was selected but the Portal
* entitlement is missing — the row won't activate until the user signs
* in to Nous Portal. */
needs_nous_auth?: boolean
/** The managed feature key (e.g. "browser") when needs_nous_auth is set. */
feature?: string
}
export function selectToolsetProvider(
name: string,
provider: string,
capability?: 'search' | 'extract',
profile?: ProfileScope
): Promise<SelectToolsetProviderResponse> {
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
body: capability ? { provider, capability } : { provider }
})
}
export function runToolsetPostSetup(
name: string,
key: string,
profile?: ProfileScope
): Promise<ActionResponse & { key: string }> {
return window.hermesDesktop.api<ActionResponse & { key: string }>({
...capabilityScoped(profile),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
method: 'POST',
body: { key }
})
}
export function getTerminalBackends(): Promise<TerminalBackendsResponse> {
return hermesApi<TerminalBackendsResponse>({
...profileScoped(),
path: '/api/tools/terminal/backends'
})
}
export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> {
return hermesApi<{ ok: boolean; backend: string }>({
...profileScoped(),
path: '/api/tools/terminal/backend',
method: 'PUT',
body: { backend }
})
}
export function getComputerUseStatus(): Promise<ComputerUseStatus> {
return hermesApi<ComputerUseStatus>({
...profileScoped(),
path: '/api/tools/computer-use/status'
})
}
export function grantComputerUsePermissions(): Promise<ActionResponse> {
return hermesApi<ActionResponse>({
...profileScoped(),
path: '/api/tools/computer-use/permissions/grant',
method: 'POST'
})
}
+4 -1
View File
@@ -492,7 +492,10 @@ function ArtifactImageCard({ artifact, failedImage, onImageError, onOpenChat }:
}, [artifact.href, artifact.id, artifact.value, onImageError])
return (
<article className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)">
<article
className="group/artifact overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background)"
data-tour="artifact-card"
>
<div
className={cn(
'relative flex h-40 w-full items-center justify-center overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-1.5',
@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils'
// Shared class names for the composer's control row, in a module of their own
// so both the row (`controls.tsx`) and the menus it renders can wear them
// without importing each other in a cycle.
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
/** A toggle that is currently ON — dictation, spoken replies, the wake word. */
export const ACTIVE_ICON_BTN = 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
@@ -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 { $hudMode } from '@/store/hud'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
@@ -57,6 +58,44 @@ async function expectShortcutTooltip(label: string, shortcut: string) {
afterEach(() => {
cleanup()
$hudMode.set(false)
})
// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
// controls fold into one menu there, and the way out of HUD mode joins the
// row instead of floating above the bar in a reserved strip. The docked
// composer keeps every control inline and shows no exit.
describe('HUD mode', () => {
it('keeps the voice controls inline and offers no exit in the docked composer', () => {
renderControls()
expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
it('folds them into one menu and offers the way out in the HUD', () => {
$hudMode.set(true)
renderControls()
expect(screen.getByLabelText('Voice')).toBeTruthy()
expect(screen.getByLabelText('Exit HUD mode')).toBeTruthy()
// Folded away, not duplicated — the whole point is the row's width back.
expect(screen.queryByLabelText('Voice dictation')).toBeNull()
expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
})
// A collapsed menu that looked idle while the mic was open would be a worse
// trade than the space it saves, so the trigger reports the live state.
it('reports a live voice state on the collapsed trigger', () => {
$hudMode.set(true)
renderControls({ voiceStatus: 'recording' })
expect(screen.getByLabelText('Stop dictation')).toBeTruthy()
expect(screen.queryByLabelText('Voice')).toBeNull()
})
})
describe('ComposerControls shortcut tooltips', () => {
+58 -28
View File
@@ -7,26 +7,18 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
import { VoiceMenu } from './voice-menu'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
export const GHOST_ICON_BTN = cn(
ICON_BTN,
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
// Send/voice-conversation primary: solid foreground-on-background circle
// (reads as black-on-white in light mode, white-on-black in dark mode) to
// match the reference composer's high-contrast CTA. Keeps the pill itself
// neutral and lets the action visually dominate the row.
export const PRIMARY_ICON_BTN = cn(
'size-(--composer-control-primary-size,var(--composer-control-size)) shrink-0 rounded-full p-0',
'bg-foreground text-background hover:bg-foreground/90',
'disabled:bg-foreground/30 disabled:text-background disabled:opacity-100'
)
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
// for these here, and the row is where they read as belonging.
export { ACTIVE_ICON_BTN, GHOST_ICON_BTN, ICON_BTN, PRIMARY_ICON_BTN } from './control-classes'
interface ConversationProps {
active: boolean
@@ -70,6 +62,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const hudMode = useStore($hudMode)
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -84,9 +77,27 @@ export function ComposerControls({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<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} />
{/* The HUD is a Spotlight bar a few hundred pixels wide, so the four
separate voice toggles fold into one menu there and leave the row to
the input. The docked composer has the width and keeps them inline —
same controls, same state, different budget. */}
{hudMode ? (
<VoiceMenu
autoSpeak={autoSpeak}
disabled={disabled}
onDictate={onDictate}
onStartConversation={conversation.onStart}
onToggleAutoSpeak={onToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}
/>
) : (
<>
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
<WakeWordButton disabled={disabled} />
</>
)}
{showQueueButton ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
@@ -142,10 +153,37 @@ export function ComposerControls({
</Button>
</Tip>
)}
{/* The way out of HUD mode, riding the controls row rather than floating
above the bar. The old chip lived in a 26px transparent strip reserved
over the composer (--hud-chip-strip), which under glass is bare
untinted material with a hidden button in it — a band of chrome above
the surface, paid for in every state, for a control that is invisible
until hovered. Here it costs no reserved space and sits with the other
things you can press. */}
{hudMode ? <ExitHudButton /> : null}
</div>
)
}
function ExitHudButton() {
const { t } = useI18n()
return (
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={cn(GHOST_ICON_BTN, 'p-0')}
onClick={closeHud}
size="icon"
type="button"
variant="ghost"
>
<Codicon name="screen-normal" size="0.875rem" />
</Button>
</Tip>
)
}
function ConversationPill({
disabled,
level,
@@ -269,11 +307,7 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
<Button
aria-label={label}
aria-pressed={active}
className={cn(
GHOST_ICON_BTN,
'p-0',
active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
onClick={() => {
triggerHaptic(active ? 'close' : 'open')
@@ -318,11 +352,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
<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'
)}
className={cn(GHOST_ICON_BTN, 'p-0', wake.listening && !pausedForVoice && ACTIVE_ICON_BTN)}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
@@ -365,7 +395,7 @@ function DictationButton({
GHOST_ICON_BTN,
'p-0',
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
status === 'recording' && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary',
status === 'recording' && ACTIVE_ICON_BTN,
status === 'transcribing' && 'bg-primary/10 text-primary'
)}
data-active={active}
+61 -8
View File
@@ -67,6 +67,8 @@ const cssEscape = (value: string): string => {
}
interface SubmitDetail {
/** Unique mounted composer surface captured at click time. */
surfaceId: string
target: ComposerTarget
text: string
/** `hidden` types the persisted user row so no bubble renders — the
@@ -155,6 +157,38 @@ const dispatch = <T>(name: string, detail: T) => {
window.setTimeout(() => window.dispatchEvent(new CustomEvent<T>(name, { detail })), 0)
}
/** Submit is the one bus mutation that must preserve the chat visible at click
* time. Deferring it lets a parent click handler/tab reveal switch the active
* keep-alive pane before subscribers run, so the task is dropped or claimed by
* another composer. Other bus events intentionally defer for focus restoration.
*/
const dispatchNow = <T>(name: string, detail: T) => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<T>(name, { detail }))
}
}
/** Unique identity for the visible composer surface addressed by a submit. */
const getVisibleComposerSurfaceId = (target: ComposerTarget): string | null => {
if (typeof document === 'undefined') {
return null
}
const surface = queryVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`)
return surface?.dataset.composerSurfaceId || null
}
const composerSurfaceIsVisible = (target: ComposerTarget, surfaceId: string): boolean => {
if (typeof document === 'undefined') {
return false
}
return queryAllVisible<HTMLElement>(`[data-composer-target="${cssEscape(target)}"]`).some(
surface => surface.dataset.composerSurfaceId === surfaceId
)
}
const subscribe = <T>(name: string, handler: (detail: T) => void) => {
if (typeof window === 'undefined') {
return () => undefined
@@ -264,17 +298,36 @@ export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail)
* the agent a task without the user round-tripping through the input. */
export const requestComposerSubmit = (
text: string,
{ target = 'active', displayKind }: { target?: ComposerTarget | 'active'; displayKind?: 'hidden' } = {}
) => {
{
displayKind,
surfaceId: requestedSurfaceId,
target = 'active'
}: { displayKind?: 'hidden'; surfaceId?: null | string; target?: ComposerTarget | 'active' } = {}
): boolean => {
const trimmed = text.trim()
if (trimmed) {
dispatch<SubmitDetail>(SUBMIT_EVENT, {
target: resolve(target),
text: trimmed,
...(displayKind ? { displayKind } : {})
})
if (!trimmed) {
return false
}
const resolvedTarget = resolve(target)
const surfaceId = requestedSurfaceId === undefined ? getVisibleComposerSurfaceId(resolvedTarget) : requestedSurfaceId
// Fail closed: without an exact visible surface identity, broadcasting a
// submit could make more than one keep-alive/new-chat composer claim it.
if (!surfaceId || (requestedSurfaceId !== undefined && !composerSurfaceIsVisible(resolvedTarget, surfaceId))) {
return false
}
dispatchNow<SubmitDetail>(SUBMIT_EVENT, {
surfaceId,
target: resolvedTarget,
text: trimmed,
...(displayKind ? { displayKind } : {})
})
return true
}
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
@@ -1,6 +1,8 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { type Dispatch, type PropsWithChildren, type SetStateAction, useLayoutEffect, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { $gateway } from '@/store/gateway'
@@ -12,23 +14,41 @@ import {
setSudoRequest
} from '@/store/prompts'
import { type ComposerTarget, requestComposerSubmit } from '../focus'
import { ComposerScopeProvider, ComposerSurfaceProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
compacting?: boolean
inputDisabled?: boolean
scopeTarget?: ComposerTarget
sessionKey?: string | null
submitOnHide?: boolean
surfaceId?: string | null
text?: string
visible?: boolean
}
let surfaceSequence = 0
function renderSubmitHook({
attachments = [],
busy = false,
compacting = false,
text = ''
inputDisabled = false,
scopeTarget = 'main',
sessionKey = 'stored-session',
submitOnHide = false,
surfaceId,
text = '',
visible = true
}: SubmitHarnessOptions = {}) {
const resolvedSurfaceId = surfaceId === undefined ? `test-surface-${++surfaceSequence}` : surfaceId
const draftRef = { current: text }
const editor = document.createElement('div')
const editor = window.document.createElement('div')
editor.dataset.slot = 'composer-rich-input'
editor.textContent = text
const editorRef = { current: editor }
@@ -36,43 +56,214 @@ function renderSubmitHook({
const onSteer = vi.fn(async () => true)
const onSubmit = vi.fn(async () => true)
const queueCurrentDraft = vi.fn(() => true)
let updatePaneVisible: Dispatch<SetStateAction<boolean>> | undefined
const clearDraft = vi.fn(() => {
draftRef.current = ''
editorRef.current!.textContent = ''
})
const hook = renderHook(() =>
useComposerSubmit({
activeQueueSessionKey: 'stored-session',
activeQueueSessionKeyRef: { current: 'stored-session' },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled: false,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
})
const Wrapper = ({ children }: PropsWithChildren) => {
const [paneVisible, setPaneVisible] = useState(visible)
updatePaneVisible = setPaneVisible
useLayoutEffect(() => {
if (submitOnHide && !paneVisible) {
requestComposerSubmit('ship while hiding', { target: scopeTarget })
}
}, [paneVisible])
return (
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, target: scopeTarget }}>
<ComposerSurfaceProvider value={resolvedSurfaceId}>
<PaneVisibleContext.Provider value={paneVisible}>
<div
data-composer-surface-id={resolvedSurfaceId ?? undefined}
data-composer-target={scopeTarget}
data-pane-hidden={paneVisible ? undefined : ''}
>
{children}
</div>
</PaneVisibleContext.Provider>
</ComposerSurfaceProvider>
</ComposerScopeProvider>
)
}
const hook = renderHook(
() =>
useComposerSubmit({
activeQueueSessionKey: sessionKey,
activeQueueSessionKeyRef: { current: sessionKey },
attachments,
busy,
compacting,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
}),
{ wrapper: Wrapper }
)
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
return {
clearDraft,
hook,
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
composerSurfaceId: resolvedSurfaceId,
setPaneVisible(nextVisible: boolean) {
if (!updatePaneVisible) {
throw new Error('Pane visibility setter was not initialized')
}
updatePaneVisible(nextVisible)
}
}
}
describe('useComposerSubmit external request routing', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('does not fan out a main ship across keep-alives or other projects', async () => {
const visibleMain = renderSubmitHook({ sessionKey: 'session-a' })
const hiddenMain = renderSubmitHook({ sessionKey: 'session-b', visible: false })
const visibleTile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
const hiddenTile = renderSubmitHook({
scopeTarget: 'tile:project-c',
sessionKey: 'other-tile',
visible: false
})
expect(requestComposerSubmit('ship this branch', { target: 'main' })).toBe(true)
await waitFor(() =>
expect(visibleMain.onSubmit).toHaveBeenCalledWith('ship this branch', {
composerScope: 'session-a'
})
)
expect(visibleMain.onSubmit).toHaveBeenCalledTimes(1)
expect(hiddenMain.onSubmit).not.toHaveBeenCalled()
expect(visibleTile.onSubmit).not.toHaveBeenCalled()
expect(hiddenTile.onSubmit).not.toHaveBeenCalled()
})
it('routes a tile-targeted submit to that tile only', async () => {
const main = renderSubmitHook({ sessionKey: 'main-session' })
const tile = renderSubmitHook({ scopeTarget: 'tile:project-b', sessionKey: 'tile-session' })
expect(requestComposerSubmit('ship project B', { target: 'tile:project-b' })).toBe(true)
await waitFor(() =>
expect(tile.onSubmit).toHaveBeenCalledWith('ship project B', {
composerScope: 'tile-session'
})
)
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('uses the captured surface id when two visible composers share a target', async () => {
const first = renderSubmitHook({ sessionKey: 'session-first' })
const second = renderSubmitHook({ sessionKey: 'session-second' })
requestComposerSubmit('ship exactly one session', { surfaceId: second.composerSurfaceId, target: 'main' })
await waitFor(() =>
expect(second.onSubmit).toHaveBeenCalledWith('ship exactly one session', {
composerScope: 'session-second'
})
)
expect(first.onSubmit).not.toHaveBeenCalled()
})
it('submits to the session visible at click time even when the same click switches tabs', async () => {
const hiddenA = renderSubmitHook({ sessionKey: 'session-a', visible: false })
const visibleB = renderSubmitHook({ sessionKey: 'session-b' })
act(() => {
requestComposerSubmit('ship session B', { target: 'main' })
visibleB.setPaneVisible(false)
hiddenA.setPaneVisible(true)
})
await waitFor(() =>
expect(visibleB.onSubmit).toHaveBeenCalledWith('ship session B', {
composerScope: 'session-b'
})
)
expect(hiddenA.onSubmit).not.toHaveBeenCalled()
})
it('does not fan out when visible composers do not have queue session keys yet', async () => {
const firstNewSession = renderSubmitHook({ sessionKey: null })
const secondNewSession = renderSubmitHook({ sessionKey: null })
act(() => {
requestComposerSubmit('ship the visible new session', { target: 'main' })
})
await waitFor(() => expect(firstNewSession.onSubmit).toHaveBeenCalledTimes(1))
expect(secondNewSession.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when the visible composer has no surface identity', () => {
const unidentified = renderSubmitHook({ surfaceId: null })
expect(requestComposerSubmit('do not broadcast this', { target: 'main' })).toBe(false)
expect(unidentified.onSubmit).not.toHaveBeenCalled()
})
it('fails closed when a pinned origin surface is no longer visible', () => {
const hidden = renderSubmitHook({ sessionKey: 'hidden-origin', visible: false })
const visible = renderSubmitHook({ sessionKey: 'other-visible' })
expect(
requestComposerSubmit('do not send to a stale origin', {
surfaceId: hidden.composerSurfaceId,
target: 'main'
})
).toBe(false)
expect(hidden.onSubmit).not.toHaveBeenCalled()
expect(visible.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a composer whose pane is hidden during the request', () => {
const main = renderSubmitHook({ submitOnHide: true })
act(() => main.setPaneVisible(false))
expect(main.onSubmit).not.toHaveBeenCalled()
})
it('does not submit through a disabled composer', () => {
const disabled = renderSubmitHook({ inputDisabled: true })
requestComposerSubmit('do not send this', { target: 'main' })
expect(disabled.onSubmit).not.toHaveBeenCalled()
})
})
describe('useComposerSubmit busy-turn routing', () => {
afterEach(() => {
cleanup()
@@ -1,5 +1,6 @@
import { type RefObject, useEffect, useRef } from 'react'
import { type RefObject, useLayoutEffect, useRef } from 'react'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
@@ -13,7 +14,7 @@ import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { pathifyRefs } from '../path-refs'
import { composerPlainText } from '../rich-editor'
import { useComposerScope } from '../scope'
import { useComposerScope, useComposerSurfaceId } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
@@ -76,7 +77,9 @@ export function useComposerSubmit({
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
const paneVisible = usePaneVisible()
const scope = useComposerScope()
const surfaceId = useComposerSurfaceId()
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
@@ -103,19 +106,26 @@ export function useComposerSubmit({
}
// External "submit this prompt" requests (e.g. the review pane's agent-ship
// button) route through the same send path. A ref keeps the listener stable
// while always calling the latest dispatchSubmit closure.
// button) route through the same send path. Match both the composer target
// and the exact visible surface captured at click time — every tile stays
// mounted, and a session can be rendered in more than one pane.
const dispatchSubmitRef = useRef(dispatchSubmit)
dispatchSubmitRef.current = dispatchSubmit
useEffect(
useLayoutEffect(
() =>
onComposerSubmitRequest(({ target, text, displayKind }) => {
if (target === 'main' && !inputDisabled) {
onComposerSubmitRequest(({ surfaceId: requestedSurfaceId, target, text, displayKind }) => {
if (
target === scope.target &&
surfaceId !== null &&
requestedSurfaceId === surfaceId &&
paneVisible &&
!inputDisabled
) {
dispatchSubmitRef.current(text, undefined, displayKind)
}
}),
[inputDisabled]
[inputDisabled, paneVisible, scope.target, surfaceId]
)
const submitDraft = () => {
@@ -36,6 +36,26 @@ import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils
* prose off and stranded a partial `folder:` in front of the chip, because the
* window it removed wasn't the token the user was typing.
*/
/** The keyup half of trigger detection, shared by both composers.
*
* If the open popover already consumed this key in keydown (Arrow/Enter/Tab/
* Escape), skip the refresh: those keys never edit text, and for Escape the
* keydown already closed the menu — refreshing here would re-detect the
* still-present `/` and instantly reopen it. It reads a ref set during keydown
* rather than `trigger`, because by keyup time React has re-rendered and
* `trigger` may already be null. */
export function triggerKeyUpHandler(consumedRef: MutableRefObject<boolean>, refreshTrigger: () => void) {
return () => {
if (consumedRef.current) {
consumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
}
export function rebuildAroundCaret(editor: HTMLDivElement, tokenLength: number, insert: DocumentFragment | string) {
const current = composerPlainText(editor)
const caret = caretOffsetInEditor(editor)
@@ -2,6 +2,8 @@ import { render } from '@testing-library/react'
import { createRef, type RefObject } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { placeCaretAtEnd } from '../test-utils'
import { useComposerUndo } from './use-composer-undo'
/** Mount the hook against a real contentEditable, exposing its API. */
@@ -36,19 +38,10 @@ function makeEditor(text: string) {
return { editor, ref }
}
const caretAtEnd = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
describe('useComposerUndo', () => {
it('restores the pre-edit text, which is what a paste destroyed', () => {
const { editor, ref } = makeEditor('before')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -69,7 +62,7 @@ describe('useComposerUndo', () => {
it('withUndoPoint banks only when the edit actually ran', () => {
const { editor, ref } = makeEditor('text')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -95,7 +88,7 @@ describe('useComposerUndo', () => {
it('claims a native historyUndo aimed at the focused editor', () => {
const { editor, ref } = makeEditor('kept')
editor.focus()
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -162,7 +155,7 @@ describe('useComposerUndo', () => {
it('reset drops history so undo cannot cross a draft swap', () => {
const { editor, ref } = makeEditor('session A')
caretAtEnd(editor)
placeCaretAtEnd(editor)
const { api, view } = mountUndo(ref, () => editor.textContent || '')
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
@@ -62,7 +63,6 @@ export function useComposerVoice({
// A tile's composer speaks ITS transcript, not the primary chat's.
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
const ownsWakeIndicatorRef = useRef(false)
const voiceStartRequest = useStore($voiceConversationStartRequest)
@@ -77,8 +77,9 @@ export function useComposerVoice({
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
const spoken = resolveSpokenReply(sessionId, messages)
if (!last || last.id === lastSpokenIdRef.current) {
if (!last || last.id === spoken?.id) {
return null
}
@@ -100,14 +101,18 @@ export function useComposerVoice({
* in order — narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
const pendingTurnResponse = () => {
const messages = $messages.get()
return collectUnspokenTurnSpeech(messages, resolveSpokenReply(sessionId, messages)?.id ?? null)
}
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
lastSpokenIdRef.current = last.id
markAssistantIdSpoken(sessionId, messages, last.id)
}
}
+3 -17
View File
@@ -50,7 +50,7 @@ import { useComposerPlaceholder } from './hooks/use-composer-placeholder'
import { useComposerPopout } from './hooks/use-composer-popout'
import { useComposerQueue } from './hooks/use-composer-queue'
import { useComposerSubmit } from './hooks/use-composer-submit'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { triggerKeyUpHandler, 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'
@@ -904,21 +904,7 @@ export function ChatBar({
}
}
const handleEditorKeyUp = () => {
// If this keyup belongs to a key the open trigger popover already consumed
// in keydown (Arrow/Enter/Tab/Escape), skip the refresh. Those keys never
// edit text, and for Escape the keydown already closed the menu — a refresh
// here would re-detect the still-present `/` and instantly reopen it. We
// read a ref set during keydown rather than `trigger`, because by keyup
// time React has re-rendered and `trigger` may already be null.
if (triggerKeyConsumedRef.current) {
triggerKeyConsumedRef.current = false
return
}
window.setTimeout(refreshTrigger, 0)
}
const handleEditorKeyUp = triggerKeyUpHandler(triggerKeyConsumedRef, refreshTrigger)
const {
dragActive,
@@ -1278,7 +1264,7 @@ export function ChatBar({
// A tile's rail reviews ITS worktree: pin the pane's scope to
// this surface's cwd. Main keeps the classic follow-the-
// active-session scope (null).
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null))}
onOpen={() => toggleReview(scope.target === 'main' ? null : (cwd ?? null), scope.target)}
onOpenWorktree={openInWorktree}
onSwitchBranch={handleSwitchBranch}
repoPath={cwd}
@@ -98,7 +98,8 @@ describe('ModelPill per-surface model label', () => {
$provider: atom('anthropic'),
$reasoningEffort: atom('high'),
$runtimeId: atom('tile-runtime'),
$storedId: atom('stored-tile')
$storedId: atom('stored-tile'),
$turnStartedAt: atom<number | null>(null)
}
render(
@@ -11,16 +11,7 @@ import {
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'
const caretIn = (editor: HTMLElement) => {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
import { placeCaretAtEnd } from './test-utils'
describe('renderComposerContents', () => {
it('renders refs and raw text without interpreting user text as HTML', () => {
@@ -160,7 +151,7 @@ describe('insertInlineRefsIntoEditor', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review'))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
@@ -172,7 +163,7 @@ describe('insertInlineRefsIntoEditor', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review '))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
@@ -185,7 +176,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')
@@ -221,7 +212,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')
@@ -237,7 +228,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -253,7 +244,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
@@ -268,7 +259,7 @@ describe('insertComposerContentsAtCaret', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
@@ -283,7 +274,7 @@ describe('insertComposerContentsAtCaret', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -298,7 +289,7 @@ describe('insertComposerContentsAtCaret', () => {
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)
placeCaretAtEnd(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
@@ -43,3 +43,15 @@ const ComposerScopeContext = createContext<ComposerScope>(MAIN_COMPOSER_SCOPE)
export const ComposerScopeProvider = ComposerScopeContext.Provider
export const useComposerScope = (): ComposerScope => useContext(ComposerScopeContext)
/**
* Unique identity for one mounted ChatView/composer pair. Session ids cannot
* fill this role: a fresh chat has no id yet, and the same stored session can
* be rendered in more than one layout pane. External submit requests pin this
* surface id at click time so exactly one composer can claim the task.
*/
const ComposerSurfaceContext = createContext<string | null>(null)
export const ComposerSurfaceProvider = ComposerSurfaceContext.Provider
export const useComposerSurfaceId = (): string | null => useContext(ComposerSurfaceContext)
@@ -0,0 +1,14 @@
/** Collapse the selection to the end of the editor's contents.
*
* The composer's undo stack, ref rendering, and trigger detection all read the
* live selection, and jsdom starts with none — without this they see a caret
* at offset 0 and take the wrong branch. */
export function placeCaretAtEnd(editor: HTMLElement) {
const range = document.createRange()
const selection = window.getSelection()!
range.selectNodeContents(editor)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
}
@@ -0,0 +1,162 @@
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
dropdownMenuRow,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
import type { ChatBarState, VoiceStatus } from './types'
export interface VoiceMenuProps {
autoSpeak: boolean
disabled: boolean
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onStartConversation: () => void
onToggleAutoSpeak: () => void
}
/**
* Every voice control behind one trigger: dictation, spoken replies, the
* "hey hermes" wake word, and starting a full conversation.
*
* The bar carried four separate icon buttons — mic, speaker, ear, and the
* voice-conversation primary — which is most of a Spotlight-width composer
* spent on toggles that are set once and rarely touched. Collapsing them costs
* one click on the rare change and gives the row back to the input.
*
* The trigger is not a static glyph: it REPORTS the loudest live voice state
* (recording, transcribing, listening for the wake word, speaking replies), so
* folding the controls away never hides the fact that something is listening.
* A collapsed menu that looked idle while the mic was hot would be a worse
* trade than the space it saves.
*/
export function VoiceMenu({
autoSpeak,
disabled,
state,
voiceStatus,
onDictate,
onStartConversation,
onToggleAutoSpeak
}: VoiceMenuProps) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
const phrase = wake.phrase || 'hey hermes'
const dictating = state.voice.active || voiceStatus !== 'idle'
const wakeListening = wake.listening
// Anything live keeps the trigger lit, so a folded menu can never look idle
// while the mic is open.
const active = dictating || wakeListening || autoSpeak
const dictationLabel =
voiceStatus === 'recording'
? c.stopDictation
: voiceStatus === 'transcribing'
? c.transcribingDictation
: c.voiceDictation
const wakeLabel = wakeListening ? c.wakeWordListening(phrase) : c.wakeWordOff(phrase)
const triggerLabel = dictating ? dictationLabel : wakeListening ? wakeLabel : c.voiceControls
return (
<DropdownMenu>
<Tip label={wake.notice && !dictating ? `${triggerLabel} — ${wake.notice}` : triggerLabel}>
<DropdownMenuTrigger asChild>
<Button
aria-label={triggerLabel}
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
disabled={disabled}
size="icon"
type="button"
variant="ghost"
>
{voiceStatus === 'recording' ? (
<Square className={cn('fill-current', iconSize.xs)} />
) : voiceStatus === 'transcribing' ? (
<Loader2 className={cn('animate-spin', iconSize.sm)} />
) : wakeListening ? (
<Ear className={iconSize.sm} />
) : (
<Codicon name="mic" size="0.875rem" />
)}
</Button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="min-w-52">
<DropdownMenuItem
className={dropdownMenuRow}
disabled={disabled}
onSelect={() => {
triggerHaptic('open')
onStartConversation()
}}
>
<AudioLines className={iconSize.sm} />
{c.startVoice}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Checkbox items, because all three are toggles the user is reading
the CURRENT state of — the reason they were pressed-state buttons
before. A plain row would fold that state away with the menu. */}
<DropdownMenuCheckboxItem
checked={dictating}
className={dropdownMenuRow}
disabled={disabled || !state.voice.enabled || voiceStatus === 'transcribing'}
onSelect={event => {
// Keep the menu open: dictation is a mode you watch, and closing
// on select hides the recording state the trigger just entered.
event.preventDefault()
triggerHaptic(dictating ? 'close' : 'open')
onDictate()
}}
>
{dictationLabel}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={autoSpeak}
className={dropdownMenuRow}
disabled={disabled}
onSelect={event => {
event.preventDefault()
triggerHaptic(autoSpeak ? 'close' : 'open')
onToggleAutoSpeak()
}}
>
{autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
{autoSpeak ? c.stopSpeakingReplies : c.speakReplies}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={wakeListening}
className={dropdownMenuRow}
disabled={disabled || wake.pending}
onSelect={event => {
event.preventDefault()
triggerHaptic(wakeListening ? 'close' : 'open')
void toggleWakeWord()
}}
>
{wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
{wakeLabel}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
+40 -13
View File
@@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type { ReadableAtom } from 'nanostores'
import type * as React from 'react'
import { memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
@@ -23,9 +23,11 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue'
import { $introSplash } from '@/store/intro-splash'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
@@ -43,7 +45,7 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { $focusedStoredSessionId, sessionTileDelegate } from '@/store/session-states'
import { $transcriptTailBySessionId } from '@/store/transcript-tail'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
@@ -56,10 +58,11 @@ import { ChatSwapOverlay } from './chat-swap-overlay'
import { ChatBar, ChatBarFallback } from './composer'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { useComposerScope } from './composer/scope'
import { ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { shouldShowIntro } from './intro-visibility'
import { ProfileTag } from './profile-tag'
import { isRouteSessionMismatch } from './route-session-state'
import { useRuntimeMessageRepository } from './runtime-repository'
@@ -320,7 +323,17 @@ function ChatRuntimeBoundary({
// Memoized: the tile caller (session-tile.tsx) and the contrib surface re-render
// on idle ticks unrelated to the chat; with stable callback props (hoisted to
// useCallback at the call sites) memo() lets the whole chat shell skip those.
export const ChatView = memo(function ChatView({
export const ChatView = memo(function ChatView(props: ChatViewProps) {
const composerSurfaceId = useId()
return (
<ComposerSurfaceProvider value={composerSurfaceId}>
<ChatViewContent {...props} />
</ComposerSurfaceProvider>
)
})
const ChatViewContent = memo(function ChatViewContent({
className,
gateway,
modelMenuContent,
@@ -355,9 +368,17 @@ export const ChatView = memo(function ChatView({
// atoms) or a tile's session slice — same component either way.
const view = useSessionView()
const composerScope = useComposerScope()
const composerSurfaceId = useComposerSurfaceId()
const isPrimary = view.kind === 'primary'
const activeSessionId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
// Multi-pane dimming: only the focused surface paints at full strength, so
// two sessions side by side read as "this one, and that one over there".
// A selector, not a plain useStore — the focused id changes on click, and a
// boolean bails every other surface out of the re-render. Sole surface ⇒
// always focused (the atom falls back to the primary's selection), so a
// single-pane workspace never dims.
const surfaceFocused = useStoreSelector($focusedStoredSessionId, focused => focused === storedId)
// Dock anchor for a session drop onto this surface: the workspace pane for the
// primary, this tile's pane id for a tile. Read by the session-drop bridge.
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
@@ -380,6 +401,7 @@ export const ChatView = memo(function ChatView({
const gatewayOpen = gatewayState === 'open'
const introPersonality = useStore($introPersonality)
const introSeed = useStore($introSeed)
const introSplash = useStore($introSplash)
// PERF: ChatView must not subscribe to the view's $messages — the atom is
// replaced on every streaming delta flush (~30×/s) and a subscription here
// re-renders the entire chat shell (header, chat bar, thread wrapper) per
@@ -444,15 +466,18 @@ export const ChatView = memo(function ChatView({
const routeSessionMismatch = isPrimary ? isRouteSessionMismatch(routedSessionId, selectedSessionId, sessions) : false
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
// scratch window, not the full-height empty state.
const showIntro =
isPrimary &&
!isAuxiliaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&
!activeSessionId &&
messagesEmpty
// scratch window, not the full-height empty state. The Appearance toggle
// turns it off everywhere else.
const showIntro = shouldShowIntro({
activeSessionId,
auxiliaryWindow: isAuxiliaryWindow(),
enabled: introSplash,
freshDraftReady,
messagesEmpty,
primary: isPrimary,
routedSessionView: isRoutedSessionView,
selectedSessionId
})
// Session is still loading if the route references a session we haven't
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
@@ -558,6 +583,8 @@ export const ChatView = memo(function ChatView({
className
)}
data-chat-surface=""
data-chat-unfocused={surfaceFocused ? undefined : ''}
data-composer-surface-id={composerSurfaceId}
data-composer-target={composerScope.target}
data-session-anchor={sessionAnchor}
>
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { shouldShowIntro } from './intro-visibility'
const showing = {
activeSessionId: null,
auxiliaryWindow: false,
enabled: true,
freshDraftReady: true,
messagesEmpty: true,
primary: true,
routedSessionView: false,
selectedSessionId: null
} as const
describe('shouldShowIntro', () => {
it('shows on a fresh draft in the primary window', () => {
expect(shouldShowIntro(showing)).toBe(true)
})
it('hides when the Appearance toggle is off', () => {
expect(shouldShowIntro({ ...showing, enabled: false })).toBe(false)
})
it('keeps the toggle authoritative over every other clause', () => {
// Off means off: no window, session, or draft state re-enables the splash.
const inputs = [
{ ...showing, auxiliaryWindow: true, enabled: false },
{ ...showing, enabled: false, freshDraftReady: false },
{ ...showing, enabled: false, primary: false },
{ ...showing, enabled: false, messagesEmpty: false }
]
for (const input of inputs) {
expect(shouldShowIntro(input)).toBe(false)
}
})
it('hides on surfaces that are not an empty primary draft', () => {
expect(shouldShowIntro({ ...showing, primary: false })).toBe(false)
expect(shouldShowIntro({ ...showing, auxiliaryWindow: true })).toBe(false)
expect(shouldShowIntro({ ...showing, freshDraftReady: false })).toBe(false)
expect(shouldShowIntro({ ...showing, routedSessionView: true })).toBe(false)
expect(shouldShowIntro({ ...showing, selectedSessionId: 'session-1' })).toBe(false)
expect(shouldShowIntro({ ...showing, activeSessionId: 'session-1' })).toBe(false)
expect(shouldShowIntro({ ...showing, messagesEmpty: false })).toBe(false)
})
})
@@ -0,0 +1,32 @@
/**
* Whether the empty-chat intro splash renders.
*
* The splash is the full-height empty state of the primary chat: it belongs to
* a fresh draft in the main window and nothing else. Auxiliary and non-primary
* windows are scratch surfaces, a routed or active session already owns the
* view, and any transcript at all means the conversation started.
*
* `enabled` is the user's Appearance toggle and outranks every other clause:
* turning the splash off never depends on which window asks.
*/
export function shouldShowIntro(input: {
activeSessionId: null | string
auxiliaryWindow: boolean
enabled: boolean
freshDraftReady: boolean
messagesEmpty: boolean
primary: boolean
routedSessionView: boolean
selectedSessionId: null | string
}): boolean {
return (
input.enabled &&
input.primary &&
!input.auxiliaryWindow &&
input.freshDraftReady &&
!input.routedSessionView &&
!input.selectedSessionId &&
!input.activeSessionId &&
input.messagesEmpty
)
}
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { normalizePreviewAddress, PreviewBrowserBar } from './preview-browser-bar'
@@ -12,6 +12,7 @@ const baseProps = {
onBack: vi.fn(),
onForward: vi.fn(),
onNavigate: vi.fn(),
onOpenExternal: vi.fn(),
onReload: vi.fn(),
onToggleConsole: vi.fn(),
onToggleDevTools: vi.fn(),
@@ -22,9 +23,16 @@ function address(rendered: ReturnType<typeof render>) {
return rendered.getByRole('textbox', { name: 'Address' }) as HTMLInputElement
}
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
function installBridge(writeClipboard: ReturnType<typeof vi.fn>) {
desktopWindow.hermesDesktop = { writeClipboard } as unknown as Window['hermesDesktop']
}
afterEach(() => {
cleanup()
vi.clearAllMocks()
delete desktopWindow.hermesDesktop
})
describe('normalizePreviewAddress', () => {
@@ -77,6 +85,8 @@ describe('PreviewBrowserBar', () => {
expect(rendered.getByRole('button', { name: 'Back' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Forward' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Reload page' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Copy URL' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Open in browser' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Show preview console' })).toBeTruthy()
expect(rendered.getByRole('button', { name: 'Open preview DevTools' })).toBeTruthy()
expect(address(rendered)).toBeTruthy()
@@ -99,7 +109,8 @@ describe('PreviewBrowserBar', () => {
it.each([
['Back', 'onBack'],
['Forward', 'onForward'],
['Reload page', 'onReload']
['Reload page', 'onReload'],
['Open in browser', 'onOpenExternal']
] as const)('fires %s', (label, handler) => {
const spy = vi.fn()
const rendered = render(<PreviewBrowserBar {...baseProps} canGoBack canGoForward {...{ [handler]: spy }} />)
@@ -227,4 +238,39 @@ describe('PreviewBrowserBar', () => {
expect(container.querySelector('.codicon-refresh')?.className).not.toContain('codicon-modifier-spin')
})
it('copies the live address from the copy-URL button', async () => {
const writeClipboard = vi.fn().mockResolvedValue(undefined)
installBridge(writeClipboard)
render(<PreviewBrowserBar {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }))
await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com'))
})
it('copies the new address after the page navigates', async () => {
const writeClipboard = vi.fn().mockResolvedValue(undefined)
installBridge(writeClipboard)
const rendered = render(<PreviewBrowserBar {...baseProps} />)
rendered.rerender(<PreviewBrowserBar {...baseProps} url="https://example.com/next" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }))
await waitFor(() => expect(writeClipboard).toHaveBeenCalledWith('https://example.com/next'))
})
it('renders the copy control inside the address field wrapper, not as a bar glyph', () => {
render(<PreviewBrowserBar {...baseProps} />)
const copyButton = screen.getByRole('button', { name: 'Copy URL' })
const address = screen.getByRole('textbox', { name: 'Address' })
// Same positioned wrapper as the field = visually inside it, like the
// code-block copy icon (inline appearance, overlay on the field's edge).
expect(copyButton.parentElement?.contains(address)).toBe(true)
expect(copyButton.className).toContain('absolute')
})
})
@@ -1,5 +1,6 @@
/**
* BROWSER BAR — back / forward / reload / address for a URL preview.
* BROWSER BAR: back / forward / reload / address / open-in-browser for a URL
* preview.
*
* The Browser tab had no way to move: no history, and the only address on
* screen was a read-only label. Every other embedded browser (VS Code's Simple
@@ -16,6 +17,7 @@
import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { PaneStripGlyph } from '@/components/ui/pane-tab'
import { useI18n } from '@/i18n'
@@ -29,6 +31,7 @@ interface PreviewBrowserBarProps {
onBack: () => void
onForward: () => void
onNavigate: (url: string) => void
onOpenExternal: () => void
onReload: () => void
onToggleConsole: () => void
onToggleDevTools: () => void
@@ -91,6 +94,7 @@ export function PreviewBrowserBar({
onBack,
onForward,
onNavigate,
onOpenExternal,
onReload,
onToggleConsole,
onToggleDevTools,
@@ -135,31 +139,51 @@ export function PreviewBrowserBar({
label={copy.reload}
onSelect={onReload}
/>
<Input
aria-invalid={invalid || undefined}
aria-label={copy.address}
inputMode="url"
onBlur={() => setDraft(null)}
onChange={event => setDraft(event.target.value)}
onFocus={event => {
setDraft(url)
event.currentTarget.select()
}}
onKeyDown={event => {
if (event.key === 'Enter') {
commit(event.currentTarget.value)
event.currentTarget.blur()
}
{/* The copy control lives INSIDE the field, on its right edge — the
same pre-faded inline icon code blocks use, not a toolbar button.
It copies what the field shows: on a remote gateway, that is the
reach-resolved address. */}
<div className="relative min-w-0 flex-1">
<Input
aria-invalid={invalid || undefined}
aria-label={copy.address}
className="pr-7"
inputMode="url"
onBlur={() => setDraft(null)}
onChange={event => setDraft(event.target.value)}
onFocus={event => {
setDraft(url)
event.currentTarget.select()
}}
onKeyDown={event => {
if (event.key === 'Enter') {
commit(event.currentTarget.value)
event.currentTarget.blur()
}
if (event.key === 'Escape') {
setDraft(null)
event.currentTarget.blur()
}
}}
placeholder={copy.addressPlaceholder}
size="xs"
spellCheck={false}
value={draft ?? url}
if (event.key === 'Escape') {
setDraft(null)
event.currentTarget.blur()
}
}}
placeholder={copy.addressPlaceholder}
size="xs"
spellCheck={false}
value={draft ?? url}
/>
<CopyButton
appearance="inline"
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-sm p-1"
iconClassName="size-3"
label={t.contextMenu.link.copyUrl}
showLabel={false}
text={url}
/>
</div>
<PaneStripGlyph
icon={<Codicon name="link-external" size="0.8125rem" />}
label={t.preview.openInBrowser}
onSelect={onOpenExternal}
/>
<PaneStripGlyph
active={consoleOpen}
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import type { PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { openGuestContextMenu } from '@/app/context-menu/store'
import { PanelEmpty } from '@/app/overlays/panel'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
@@ -29,21 +30,48 @@ import { previewConsoleState } from './preview-console-store'
import { LocalFilePreview, PreviewEmptyState } from './preview-file'
import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav'
import { registerPreviewPageReader } from './preview-reader'
import { registerPreviewTourRunner } from './preview-tour-runner'
type PreviewWebview = HTMLElement & {
canGoBack?: () => boolean
canGoForward?: () => boolean
closeDevTools?: () => void
copy?: () => void
cut?: () => void
executeJavaScript?: (code: string) => Promise<unknown>
getTitle?: () => string
getURL?: () => string
getWebContentsId?: () => number
goBack?: () => void
goForward?: () => void
inspectElement?: (x: number, y: number) => void
isDevToolsOpened?: () => boolean
loadURL?: (url: string) => Promise<void>
openDevTools?: () => void
paste?: () => void
reload?: () => void
reloadIgnoringCache?: () => void
replaceMisspelling?: (word: string) => void
selectAll?: () => void
}
/** The raw Chromium params riding the webview tag's `context-menu` event. */
interface GuestContextMenuParams {
dictionarySuggestions?: string[]
editFlags?: {
canCopy?: boolean
canCut?: boolean
canPaste?: boolean
canSelectAll?: boolean
}
hasImageContents?: boolean
isEditable?: boolean
linkURL?: string
misspelledWord?: string
selectionText?: string
srcURL?: string
x: number
y: number
}
interface PreviewPaneProps {
@@ -427,6 +455,25 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
})
}, [isWebPreview, tabId])
// Publish the TOUR runner for this tab (the tour tool, surface='preview'):
// runs injected driver.js actions inside the guest page so the agent can
// give guided walkthroughs of whatever web app is open here.
useEffect(() => {
if (!isWebPreview || !tabId) {
return
}
return registerPreviewTourRunner(tabId, async code => {
const webview = webviewRef.current
if (!webview?.executeJavaScript) {
throw new Error('preview webview is not ready')
}
return webview.executeJavaScript(code)
})
}, [isWebPreview, tabId])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!consoleOpen) {
@@ -723,7 +770,82 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
const onDevToolsOpened = () => setDevtoolsOpen(true)
const onDevToolsClosed = () => setDevtoolsOpen(false)
// Right-clicks INSIDE the guest page. The tag surfaces Chromium's full
// context-menu params (link, image, editable, selection, spellcheck), so
// the app coordinator renders the same translated menu it shows
// everywhere else.
//
// Coordinates: params.x/y are WINDOW-relative device-independent pixels
// — the guest offset is already included, and CSS values are multiplied
// by the window zoom factor. Measured live (zoom 0.9): a click whose
// true window CSS point was (901, 272) arrived as params (811, 246) =
// (901*0.9, 272*0.9). Dividing by the zoom factor recovers CSS
// coordinates; adding the webview rect on top double-counted the offset
// and dropped the menu far right+below the click.
const onGuestContextMenu = (event: Event) => {
const detail = event as Event & { params?: GuestContextMenuParams }
const params = detail.params
if (!params) {
return
}
const zoom = window.hermesDesktop?.zoom?.factor?.() || 1
// Window CSS point of the click (the menu anchors here).
const windowX = params.x / zoom
const windowY = params.y / zoom
// Guest CSS point (inspectElement wants coordinates INSIDE the page):
// subtract the webview's own offset from the window point.
const rect = webview.getBoundingClientRect()
const guestX = Math.max(0, Math.round(windowX - rect.left))
const guestY = Math.max(0, Math.round(windowY - rect.top))
openGuestContextMenu(
windowX,
windowY,
{
dictionarySuggestions: Array.isArray(params.dictionarySuggestions) ? params.dictionarySuggestions : [],
// Chromium's availability verdict for the edit verbs. Absent only
// if a future Electron drops it — then everything stays enabled,
// which is the pre-editFlags behavior, not a lockout.
editFlags: {
canCopy: params.editFlags?.canCopy ?? true,
canCut: params.editFlags?.canCut ?? true,
canPaste: params.editFlags?.canPaste ?? true,
canSelectAll: params.editFlags?.canSelectAll ?? true
},
hasImageContents: Boolean(params.hasImageContents),
isEditable: Boolean(params.isEditable),
linkURL: params.linkURL || '',
misspelledWord: params.misspelledWord || '',
selectionText: params.selectionText || '',
srcURL: params.srcURL || ''
},
{
addToDictionary: (word: string) => {
const webContentsId = webview.getWebContentsId?.()
if (typeof webContentsId === 'number') {
void window.hermesDesktop?.contextMenuGuestAddWord?.({ webContentsId, word })
}
},
copyImage: () => void window.hermesDesktop?.contextMenuCopyImage?.(),
// The tag's edit commands act on the focused webContents, and the
// menu click just parked focus on the HOST body — measured live:
// selectAll() with host focus selected the address bar + chat
// instead of the page. Focus the webview first, every verb.
editCommand: (command: 'copy' | 'cut' | 'paste' | 'selectAll') => {
webview.focus()
webview[command]?.()
},
inspectElement: () => webview.inspectElement?.(guestX, guestY),
replaceMisspelling: (word: string) => webview.replaceMisspelling?.(word)
}
)
}
webview.addEventListener('console-message', onConsole)
webview.addEventListener('context-menu', onGuestContextMenu)
webview.addEventListener('devtools-closed', onDevToolsClosed)
webview.addEventListener('devtools-opened', onDevToolsOpened)
webview.addEventListener('did-fail-load', onFail)
@@ -736,6 +858,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
return () => {
webview.removeEventListener('console-message', onConsole)
webview.removeEventListener('context-menu', onGuestContextMenu)
webview.removeEventListener('devtools-closed', onDevToolsClosed)
webview.removeEventListener('devtools-opened', onDevToolsOpened)
webview.removeEventListener('did-fail-load', onFail)
@@ -803,6 +926,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
onBack={goBack}
onForward={goForward}
onNavigate={navigateTo}
onOpenExternal={() => void window.hermesDesktop?.openExternal(currentUrl)}
onReload={reloadPreview}
onToggleConsole={() => consoleState.setOpen(open => !open)}
onToggleDevTools={toggleDevTools}
@@ -0,0 +1,38 @@
/**
* PREVIEW TOUR RUNNER REGISTRY — the tour tool's script bridge into the
* preview pane, the tour analog of preview-nav's handle registry.
*
* A live browser pane registers a script runner (its webview's
* `executeJavaScript`) here, keyed by tab id; `activeTourRunner` resolves the
* ACTIVE tab from the store. Kept separate from preview-tour.ts so the pane
* component's static import stays tiny — the engine source + driver.js
* payload only load when a tour actually runs (gateway-event dynamic-imports
* preview-tour.ts).
*/
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs } from '@/store/preview'
/** Runs JS source in the pane's guest page, resolving its completion value. */
export type TourScriptRunner = (code: string) => Promise<unknown>
const runners = new Map<string, TourScriptRunner>()
/** Register a live preview's script runner; returns an idempotent unregister. */
export function registerPreviewTourRunner(tabId: string, runner: TourScriptRunner): () => void {
runners.set(tabId, runner)
return () => {
if (runners.get(tabId) === runner) {
runners.delete(tabId)
}
}
}
/** The ACTIVE preview tab's script runner. Null = no live page to tour. */
export function activeTourRunner(): TourScriptRunner | null {
const tabs = $previewTabs.get()
const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0]
return (tab && runners.get(tab.id)) || null
}
@@ -0,0 +1,67 @@
/**
* PREVIEW TOUR — runs tour actions inside the preview pane's guest page, so a
* tour can walk through ANY web app open in the in-app browser, not just
* Hermes itself.
*
* The guest page is out-of-process; nothing here can touch its DOM directly.
* Instead the first action injects a self-contained bundle over
* `executeJavaScript` — the vendored driver.js IIFE, its stylesheet, and the
* same engine/collector SOURCE the app surface runs (see lib/tour/engine.ts's
* self-containment contract) — parked on window globals so subsequent actions
* reuse the live driver instance. Injection is idempotent and vanishes with
* the page (a navigation resets the tour, which is the right behavior).
*
* Dynamic-imported by run-tour.ts so the raw driver.js payload stays out of
* the boot path.
*/
import driverCss from 'driver.js/dist/driver.css?raw'
import driverIife from 'driver.js/dist/driver.js.iife.js?raw'
import { collectTourTargets } from '@/lib/tour/collect-targets'
import { runTourEngine, type TourAction, type TourResult } from '@/lib/tour/engine'
import { activeTourRunner } from './preview-tour-runner'
/** Build the idempotent inject-and-run script for one tour action. */
function buildTourScript(action: TourAction): string {
return `(function () {
var w = window;
if (!w.__hermesTourEngine) {
${driverIife}
w.__hermesTourHolder = {};
w.__hermesTourCollect = (${collectTourTargets.toString()});
w.__hermesTourEngine = (${runTourEngine.toString()});
}
if (!document.getElementById('__hermes-tour-style')) {
var style = document.createElement('style');
style.id = '__hermes-tour-style';
style.textContent = ${JSON.stringify(driverCss)};
(document.head || document.documentElement).appendChild(style);
}
return JSON.stringify(w.__hermesTourEngine(
w.driver.js.driver,
w.__hermesTourHolder,
${JSON.stringify(action)},
w.__hermesTourCollect,
document
));
})()`
}
/** Run one tour action in the ACTIVE preview tab's page. */
export async function runPreviewTour(action: TourAction): Promise<TourResult> {
const run = activeTourRunner()
if (!run) {
return { error: 'No live page is open in the preview pane — open one first.', success: false }
}
const raw = await run(buildTourScript(action))
if (typeof raw !== 'string' || !raw) {
return { error: 'The page did not answer the tour action.', success: false }
}
return JSON.parse(raw) as TourResult
}

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