Merge remote-tracking branch 'origin/main' into feat/relay-slack-blockkit-native-parity

# Conflicts:
#	gateway/relay/adapter.py
This commit is contained in:
Victor Kyriazakos 2026-07-28 11:43:11 +00:00
commit dd866eef34
837 changed files with 60951 additions and 6100 deletions

View file

@ -115,6 +115,11 @@ jobs:
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
infographic-check:
name: Check no committed infographics
needs: detect
uses: ./.github/workflows/infographic-check.yml
lockfile-diff:
name: package-lock.json diff
needs: detect

78
.github/workflows/infographic-check.yml vendored Normal file
View file

@ -0,0 +1,78 @@
name: Infographic Check
# Rejects PRs that commit PR-infographic images into the repo.
#
# PR infographics are rendered to an image-provider URL (fal.media) and
# embedded in the PR *description*. The PR body is the archive; the binary
# never belongs in git history.
#
# This has now leaked twice. PR #48261 removed the first batch, PR #54564
# removed a second batch and added `infographic/` to `.gitignore` — but
# `.gitignore` only stops *accidental* `git add`. It does nothing against
# `git add -f`, and it does nothing for a path that does not literally match
# the ignore pattern. Nine more PNGs (~14MB) were committed in the four
# weeks AFTER that rule landed, plus PR #70552 caught an `infograficos/`
# spelling that sidestepped the pattern entirely.
#
# A passive ignore rule cannot enforce a policy. This check can.
on:
workflow_call:
outputs:
review_status:
description: "JSON array of review_status objects for the synthesizer."
value: ${{ jobs.check-no-committed-infographics.outputs.review_status }}
permissions:
contents: read
jobs:
check-no-committed-infographics:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
review_status: ${{ steps.infographic-check.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- id: infographic-check
name: Reject committed PR-infographic images
run: |
# Match on the IMAGE, not on a directory name. Keying this to
# `infographic/` is what let `infograficos/` through in #70552 —
# any localized or typo'd directory would sidestep it again.
# Instead: find tracked raster images whose path contains an
# infographic-ish segment, in any spelling, at any depth.
#
# `docs/assets` and `website/` legitimately hold product imagery
# and are excluded; those are referenced from shipped docs pages.
OFFENDERS=$(git ls-files -z \
| tr '\0' '\n' \
| grep -iE '(^|/)(infograph|infograf)[^/]*/' \
| grep -iE '\.(png|jpe?g|webp|gif)$' \
|| true)
if [ -n "$OFFENDERS" ]; then
COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ')
STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached <path-to-image>\n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://<provider-url>)\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo ""
echo "::error::${COUNT} PR-infographic image(s) are tracked in git."
echo ""
printf '%s\n' "$OFFENDERS" | sed 's/^/ /'
echo ""
echo "PR infographics are rendered to an image-provider URL and"
echo "embedded in the PR DESCRIPTION. The PR body is the archive —"
echo "the binary never enters git history."
echo ""
echo "This rule has been re-established twice already (#48261,"
echo "#54564) and leaked both times, because .gitignore cannot stop"
echo "'git add -f' or a differently-spelled directory (#70552)."
echo ""
echo "To fix:"
echo " git rm --cached <path> # keeps your local copy"
echo " # then embed the provider URL in the PR description"
exit 1
fi
echo "::notice::No committed PR-infographic images."
echo "review_status=[]" >> "$GITHUB_OUTPUT"

13
.gitignore vendored
View file

@ -152,6 +152,11 @@ docs/superpowers/*
.update-incomplete
.update-incomplete.lock
# Checkout fingerprint the __pycache__ tree was last validated against
# (launch-time stale-bytecode sweep). Runtime state, never a code change.
.bytecode-fingerprint
.bytecode-fingerprint.tmp
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
@ -175,5 +180,13 @@ apps/desktop/demo/
# image-provider (fal.media) URL — they are NEVER committed to the repo. The
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
#
# Spelling variants are listed because a single `infographic/` pattern was
# sidestepped by an `infograficos/` directory (#70552). .gitignore is only
# the first line of defence and cannot stop `git add -f` at all — the
# infographic-check CI job is what actually enforces this.
infographic/
infographics/
infograficos/
infografico/
native/fts5_cjk/*.so

View file

@ -325,7 +325,7 @@ class AIAgent:
provider: str = None,
api_mode: str = None, # "chat_completions" | "codex_responses" | ...
model: str = "", # empty → resolved from config/provider later
max_iterations: int = 90, # tool-calling iterations (shared with subagents)
max_iterations: int = 500, # tool-calling iterations (shared with subagents)
enabled_toolsets: list = None,
disabled_toolsets: list = None,
quiet_mode: bool = False,

View file

@ -1,3 +1,45 @@
# Debian 13 still ships SQLite 3.46.1, which contains the upstream WAL-reset
# corruption bug. Build a pinned shared library for the runtime image instead
# of relying on a distro backport that trixie does not currently provide.
# See #70480 and https://sqlite.org/wal.html#walresetbug.
FROM debian:13.4 AS sqlite_build
ARG SQLITE_AUTOCONF_VERSION=3530400
ARG SQLITE_SHA256=0e9483900e92cd5de8fd48d16bf9200145a61f7fd5be542a5ac81d8a9516eb9c
RUN apt-get -o Acquire::Retries=3 update && \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
build-essential ca-certificates curl && \
rm -rf /var/lib/apt/lists/* && \
(curl -fsSL --retry 1 --retry-all-errors --connect-timeout 15 --max-time 60 \
-o /tmp/sqlite.tar.gz \
"https://sqlite.org/2026/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz" || \
curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 --max-time 120 \
-o /tmp/sqlite.tar.gz \
"https://sources.buildroot.net/sqlite/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}.tar.gz") && \
printf '%s %s\n' "${SQLITE_SHA256}" /tmp/sqlite.tar.gz > /tmp/sqlite.sha256 && \
sha256sum -c /tmp/sqlite.sha256 && \
tar -xzf /tmp/sqlite.tar.gz -C /tmp && \
cd "/tmp/sqlite-autoconf-${SQLITE_AUTOCONF_VERSION}" && \
CFLAGS="-O2 \
-DSQLITE_ENABLE_FTS3 \
-DSQLITE_ENABLE_FTS3_PARENTHESIS \
-DSQLITE_ENABLE_FTS4 \
-DSQLITE_ENABLE_FTS5 \
-DSQLITE_ENABLE_RTREE \
-DSQLITE_ENABLE_GEOPOLY \
-DSQLITE_ENABLE_COLUMN_METADATA \
-DSQLITE_ENABLE_UNLOCK_NOTIFY \
-DSQLITE_ENABLE_DBSTAT_VTAB \
-DSQLITE_ENABLE_DBPAGE_VTAB \
-DSQLITE_ENABLE_MATH_FUNCTIONS \
-DSQLITE_ENABLE_PREUPDATE_HOOK \
-DSQLITE_ENABLE_SESSION \
-DSQLITE_SECURE_DELETE \
-DSQLITE_THREADSAFE=1 \
-DSQLITE_MAX_VARIABLE_NUMBER=250000" \
./configure --prefix=/opt/sqlite-fixed --disable-static && \
make -j"$(nproc)" && \
make install
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
# which reached EOL in April 2026 — we copy node + npm + corepack from the
@ -31,6 +73,23 @@ RUN apt-get -o Acquire::Retries=3 update && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# Prefer the fixed SQLite over Debian's vulnerable libsqlite3.so.0. Keep the
# public library name stable so both the system interpreter and the uv-created
# venv resolve the replacement without changing Python import paths.
COPY --from=sqlite_build /opt/sqlite-fixed/lib/libsqlite3.so.3.53.4 /usr/local/lib/
RUN ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so.0 && \
ln -sf libsqlite3.so.3.53.4 /usr/local/lib/libsqlite3.so && \
printf '/usr/local/lib\n' > /etc/ld.so.conf.d/000-sqlite-fixed.conf && \
ldconfig && \
python3 -c "import sqlite3, sys; \
v = sqlite3.sqlite_version_info; \
sys.exit(f'linked SQLite {sqlite3.sqlite_version} still has the WAL-reset bug') if v < (3, 51, 3) else None; \
db = sqlite3.connect(':memory:'); \
db.execute(\"CREATE VIRTUAL TABLE docs USING fts5(content, tokenize='trigram')\"); \
db.execute(\"INSERT INTO docs VALUES ('hermes')\"); \
sys.exit('SQLite FTS5 trigram self-test failed') if db.execute(\"SELECT count(*) FROM docs WHERE docs MATCH 'erm'\").fetchone()[0] != 1 else None; \
db.close()"
# ---------- s6-overlay install ----------
# s6-overlay provides supervision for the main hermes process, the dashboard,
# and per-profile gateways. /init becomes PID 1 below — see ENTRYPOINT.

View file

@ -455,7 +455,7 @@ def init_agent(
command: str = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
max_iterations: int = 500, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
@ -529,7 +529,7 @@ def init_agent(
requested_provider (str): Original provider identity before runtime canonicalization
api_mode (str): API mode override: "chat_completions" or "codex_responses"
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
max_iterations (int): Maximum number of tool calling iterations (default: 90)
max_iterations (int): Maximum number of tool calling iterations (default: 500)
tool_delay (float): Delay between tool calls in seconds (default: 1.0)
enabled_toolsets (List[str]): Only enable tools from these toolsets (optional)
disabled_toolsets (List[str]): Disable tools from these toolsets (optional)
@ -645,6 +645,13 @@ def init_agent(
# AWS Bedrock — auto-detect from provider name or base URL
# (bedrock-runtime.<region>.amazonaws.com).
agent.api_mode = "bedrock_converse"
elif agent.provider in {"nous", "nous-portal", "nousresearch"}:
# Portal is dual-wire: anthropic/* → Messages, everything else →
# chat_completions. Callers that already pass api_mode win above;
# this covers direct AIAgent construction without a resolved runtime.
from hermes_cli.providers import nous_api_mode
agent.api_mode = nous_api_mode(agent.model)
else:
agent.api_mode = "chat_completions"
@ -951,12 +958,6 @@ def init_agent(
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Displayed reasoning text streamed during the current model response,
# captured only when a surface consumed it via a reasoning callback. Used
# by active-turn redirect to checkpoint what the user actually saw without
# ever persisting hidden provider reasoning.
agent._current_streamed_reasoning_text = ""
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
@ -1155,6 +1156,10 @@ def init_agent(
elif base_url_host_matches(effective_base, "chatgpt.com"):
from agent.auxiliary_client import _codex_cloudflare_headers
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
elif base_url_host_matches(effective_base, "x.ai"):
from tools.xai_http import hermes_xai_default_headers
client_kwargs["default_headers"] = hermes_xai_default_headers()
elif "default_headers" not in client_kwargs:
# Fall back to profile.default_headers for providers that
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
@ -1952,8 +1957,12 @@ def init_agent(
# parent_session_id chain, no `name #N` renumber). See #38763 and
# agent/conversation_compression.py. Consumed by compress_context(), not the
# compressor, so it rides on the agent.
# Default True must match DEFAULT_CONFIG["compression"]["in_place"]
# (#38763). default=False here previously flipped agents into rotation
# mode whenever the merged config omitted the key (partial configs,
# load_config failure → {}), re-arming the pre-lease drift abort.
compression_in_place = is_truthy_value(
_compression_cfg.get("in_place"), default=False
_compression_cfg.get("in_place"), default=True
)
codex_app_server_auto_compaction = str(
_compression_cfg.get("codex_app_server_auto", "native") or "native"

View file

@ -1221,7 +1221,15 @@ def try_recover_primary_transport(
if agent._is_openrouter_url():
return False
provider_lower = (agent.provider or "").strip().lower()
if provider_lower in {"nous", "nous-research"}:
# Portal OpenAI-wire traffic still rides aggregator retry infra, so one
# more rebuilt OpenAI client won't help. Portal Claude on the native
# Messages route holds a local Anthropic SDK client whose connection
# pool *does* need the rebuild every other anthropic_messages provider
# already gets — don't blanket-skip the dual-wire path.
if (
provider_lower in {"nous", "nous-portal", "nousresearch"}
and getattr(agent, "api_mode", None) != "anthropic_messages"
):
return False
try:
@ -1895,7 +1903,15 @@ def anthropic_prompt_cache_policy(
if is_native_anthropic:
return True, True
if (is_openrouter or is_nous_portal) and (is_claude or is_kimi):
# Envelope layout is an OpenAI-wire construct. Portal Claude on the native
# Messages route must fall through to the third-party anthropic_messages
# branch below, which emits inner-block cache_control breakpoints; the
# envelope form would be dropped and serve 0% cache hits.
if (
(is_openrouter or is_nous_portal)
and (is_claude or is_kimi)
and not is_anthropic_wire
):
return True, False
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
# cache_control path as Portal Claude. Portal proxies to OpenRouter
@ -2059,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
from hermes_cli.providers import determine_api_mode
# ── Determine api_mode if not provided ──
# Pass model so dual-wire providers (Nous Portal anthropic/* → Messages)
# resolve correctly; without it determine_api_mode falls back to the
# openai_chat overlay default.
if not api_mode:
api_mode = determine_api_mode(new_provider, base_url)
api_mode = determine_api_mode(new_provider, base_url, model=new_model)
# Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing
# /v1 into the anthropic_messages client, which would cause the SDK to
@ -2614,6 +2633,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
_clarify_tool(
question=next_args.get("question", ""),
choices=next_args.get("choices"),
multi_select=next_args.get("multi_select", False),
callback=agent.clarify_callback,
),
next_args,
@ -2759,6 +2779,129 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
# Placeholder substituted for an empty non-final message that would otherwise
# make the provider reject the whole request. Kept identical to the stub-
# creation placeholder in chat_completion_helpers so a healed transcript reads
# consistently whether the empty turn was caught at write time or send time.
_INTERRUPTED_PLACEHOLDER = "[response interrupted]"
def _msg_has_payload(msg: Dict[str, Any]) -> bool:
"""True if ``msg`` carries anything the API treats as non-empty content.
Covers string content, non-empty multimodal content lists, tool_calls,
tool_call_id linkage (tool results), and reasoning payloads. Mirrors the
emptiness checks used by ``AIAgent._is_thinking_only_assistant`` but is
role-agnostic so it can vet user/assistant/tool turns uniformly.
"""
content = msg.get("content")
if isinstance(content, str):
if content.strip():
return True
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
# any typed block (text/image/tool_use/document/...) counts,
# as long as a text block is not itself blank
if block.get("type") == "text":
if isinstance(block.get("text"), str) and block["text"].strip():
return True
continue
return True
elif block:
return True
elif content not in (None, ""):
return True
# Structural payloads that make an "empty-content" message still valid.
if msg.get("tool_calls"):
return True
if isinstance(msg.get("reasoning_content"), str) and msg["reasoning_content"].strip():
return True
if msg.get("reasoning") or msg.get("reasoning_details"):
return True
# Codex Responses item carriers: a commentary-phase assistant turn
# persists with content:"" by DESIGN — its text lives in
# ``codex_message_items`` (delivered via the interim callback) and the
# structured items are replayed for prefix-cache hits. Same for
# ``codex_reasoning_items``. These turns are never wire-empty on any
# api_mode: the codex transport replays the items, and the
# chat-completions transport strips the carriers only after this repair
# pass has already run. Treat them as payload so the repair never
# rewrites a designed-empty codex turn (July 2026: a write-time pad that
# ignored this broke codex commentary replay in CI).
if msg.get("codex_message_items") or msg.get("codex_reasoning_items"):
return True
return False
def repair_empty_non_final_messages(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Heal empty-content non-final messages before they reach the provider.
Root-cause context: a stream that dies with 0 recovered characters (peer
reset, stall-kill) could persist an assistant turn with ``content=None``
and no tool_calls. The Anthropic message schema and the litellm/Bedrock
proxies in front of it reject ANY request whose transcript contains an
empty non-final message:
"all messages must have non-empty content except for the optional
final assistant message" (HTTP 400 INVALID_REQUEST_BODY)
Once such a message lands mid-transcript it poisons EVERY subsequent turn
of that session until it scrolls out of context. The write-time guard in
``chat_completion_helpers`` stops NEW stubs, but sessions already carrying
one (persisted before the guard, or fed in from a host history) stay stuck
and previously needed a manual DB edit + gateway restart to recover.
This pass is the self-healing counterpart: it runs unconditionally on the
per-call ``api_messages`` copy, so a poisoned transcript repairs itself
IN MEMORY on the very next send no restart, no DB surgery. The final
message is left untouched (an empty final assistant turn is legal). The
stored conversation history is never mutated; only the wire copy is
repaired, so the UI/session trace stays faithful.
Repair strategy is substitution, not deletion: dropping a mid-transcript
turn can break role alternation and tool-call pairing, whereas an honest
minimal placeholder keeps the sequence intact and reads correctly as an
interrupted turn on replay.
"""
if not messages or len(messages) < 2:
return messages
repaired: List[Dict[str, Any]] = []
healed = 0
last_idx = len(messages) - 1
for idx, msg in enumerate(messages):
if (
idx != last_idx
and isinstance(msg, dict)
# tool results are validated by their own orphan/pairing pass; an
# empty tool result is a separate (and rarer) concern.
and msg.get("role") in ("assistant", "user")
and not _msg_has_payload(msg)
):
# Shallow-copy so stored history / prompt caching stays byte-stable.
fixed = dict(msg)
fixed["content"] = _INTERRUPTED_PLACEHOLDER
repaired.append(fixed)
healed += 1
else:
repaired.append(msg)
if healed:
_ra().logger.warning(
"Pre-call sanitizer: healed %d empty non-final message(s) by "
"substituting placeholder content — an empty-content turn was in "
"the transcript and would 400 the request ('messages must have "
"non-empty content' / INVALID_REQUEST_BODY). Self-recovering the "
"poisoned transcript in memory; no restart needed.",
healed,
)
return repaired
return messages
def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Fix orphaned tool_call / tool_result pairs before every LLM call.
@ -2779,6 +2922,15 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
filtered.append(msg)
messages = filtered
# --- Heal empty-content non-final messages (self-recovery) ---
# A dead stream can leave an empty assistant stub (or an empty user turn)
# mid-transcript; the provider then 400s EVERY subsequent request until it
# scrolls out. Repair it here, on the per-call copy, so a poisoned session
# recovers itself in memory on the next send — no restart, no DB edit.
# Done first so a substituted turn participates normally in the tool-pair
# and dedup passes below.
messages = repair_empty_non_final_messages(messages)
# --- Drop empty / malformed tool_calls arrays on assistant messages ---
# An assistant message carrying ``tool_calls: []`` (an empty array) — or a
# non-list value under the key — is semantically identical to an assistant

View file

@ -23,7 +23,7 @@ from urllib.parse import urlparse
from hermes_constants import get_hermes_home
from typing import Any, Dict, List, Optional, Tuple
from utils import base_url_host_matches, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
# NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls
# ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.)
@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
return "/anthropic" in normalized.rstrip("/").lower()
def _is_nous_portal_endpoint(base_url: str | None) -> bool:
"""Return True for Nous Portal's Anthropic Messages route.
Portal serves its ``anthropic/*`` catalog natively at
``https://inference-api.nousresearch.com/v1/messages``. Portal-specific
behaviours key off this: Bearer JWT auth, verbatim catalog model ids,
and native thinking-signature replay.
Trusted hosts only:
1. Prod hostname ``inference-api.nousresearch.com``
2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview)
Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are
rejected (hostname match, not substring).
"""
if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return True
try:
from hermes_cli.auth import _nous_inference_env_override
override = _nous_inference_env_override()
except Exception:
return False
if not override:
return False
# Exact host equality (not subdomain) so the env override can't broaden
# into sibling hosts the operator did not set.
override_host = base_url_hostname(override)
return bool(override_host) and base_url_hostname(base_url or "") == override_host
def _requires_bearer_auth(base_url: str | None) -> bool:
"""Return True for Anthropic-compatible providers that require Bearer auth.
Some third-party /anthropic endpoints implement Anthropic's Messages API but
require Authorization: Bearer instead of Anthropic's native x-api-key header.
MiniMax's global and China Anthropic-compatible endpoints, Azure AI
Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy
follow this pattern.
Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous
Portal's Messages route follow this pattern.
"""
if _is_nous_portal_endpoint(base_url):
return True
normalized = _normalize_base_url_text(base_url)
if not normalized:
return False
@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook(
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
return _anthropic_sdk.Anthropic(**kwargs)
client = _anthropic_sdk.Anthropic(**kwargs)
# Same env-inference trap as build_anthropic_client: auth_token-only
# construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key.
client.api_key = None
return client
def build_anthropic_client(
@ -850,7 +888,16 @@ def build_anthropic_client(
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}
return _anthropic_sdk.Anthropic(**kwargs)
client = _anthropic_sdk.Anthropic(**kwargs)
# Bearer-only construction leaves ``api_key`` unset, so the SDK fills it
# from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from
# ``~/.hermes/.env``). The result is dual auth —
# ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer <portal-jwt>`` —
# on every Portal / MiniMax / OAuth Messages request. Clear the env-filled
# key whenever we intentionally authenticated via auth_token alone.
if "auth_token" in kwargs and "api_key" not in kwargs:
client.api_key = None
return client
def build_anthropic_bedrock_client(region: str):
@ -2060,6 +2107,28 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, m.get("cache_control")
)
# apply_anthropic_cache_control marks an assistant turn with
# non-empty text by writing cache_control INTO ``content`` (see
# _apply_cache_marker's list branch), not at the top level. This
# branch rebuilds the message from ordered_blocks and never reads
# ``content``, so that marker would be dropped -- and because
# _can_carry_marker already counted this message as a carrier, the
# breakpoint is burned rather than relocated. #56195 covered the
# complementary shape (blank content -> top-level marker); this is
# the interleaved thinking + preamble-text + tool_use shape.
_inline_cc = None
_msg_content = m.get("content")
if isinstance(_msg_content, list):
for _blk in _msg_content:
if isinstance(_blk, dict) and isinstance(
_blk.get("cache_control"), dict
):
_inline_cc = _blk["cache_control"]
break
if _inline_cc is not None:
_apply_assistant_cache_control_to_last_cacheable_block(
replayed, _inline_cc
)
return {"role": "assistant", "content": replayed}
blocks = _extract_preserved_thinking_blocks(m)
@ -2394,10 +2463,22 @@ def _manage_thinking_signatures(
replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and
hermes-agent#16748 (DeepSeek).
Nous Portal's ``/v1/messages`` route is the exception among third-party
hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the
same signed thinking blocks. Sticky ``session_id`` keeps a conversation
on one upstream instance so those signatures stay warm stripping them
here would 400 the first tool-loop turn ("thinking must be passed back").
Portal therefore takes the native Anthropic replay path below.
Mutates ``result`` in place.
"""
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
# Portal speaks Anthropic's thinking contract end-to-end; do not treat it
# as a signature-blind proxy even though the host is not anthropic.com.
_is_third_party = (
_is_third_party_anthropic_endpoint(base_url)
and not _is_nous_portal_endpoint(base_url)
)
last_assistant_idx = None
for i in range(len(result) - 1, -1, -1):
@ -2654,7 +2735,12 @@ def build_anthropic_kwargs(
)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
model = normalize_model_name(model, preserve_dots=preserve_dots)
# Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``);
# normalizing to the bare Anthropic slug would make the model unresolvable
# there. Skipping the call preserves the prefix AND the dots, so
# ``preserve_dots`` stays irrelevant for Portal.
if not _is_nous_portal_endpoint(base_url):
model = normalize_model_name(model, preserve_dots=preserve_dots)
# effective_max_tokens = output cap for this call (≠ total context window)
# Use the resolver helper so non-positive values (negative ints,
# fractional floats, NaN, non-numeric) fail locally with a clear error
@ -2894,6 +2980,7 @@ def create_anthropic_message(
log_prefix: str = "",
prefer_stream: bool = True,
on_stream_event=None,
on_response=None,
) -> Any:
"""Create an Anthropic message, aggregating via stream when available.
@ -2910,6 +2997,13 @@ def create_anthropic_message(
progress hook so a slow-but-generating summary model isn't treated as
hung. Only fires on the streaming path; the ``create()`` fallback has no
events to report.
``on_response``: optional callable invoked once with the underlying httpx
response before the message is aggregated (best-effort, exceptions
swallowed). Response *headers* carry out-of-band provider state that the
parsed ``Message`` drops Nous Portal's ``x-nous-credits-*`` balance family
in particular. Only fires on the streaming path, which is the one the main
turn loop takes.
"""
sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix)
@ -2920,6 +3014,14 @@ def create_anthropic_message(
stream_kwargs.pop("stream", None)
try:
with stream_fn(**stream_kwargs) as stream:
if callable(on_response):
try:
on_response(getattr(stream, "response", None))
except Exception:
logger.debug(
"%son_response callback failed",
log_prefix, exc_info=True,
)
if callable(on_stream_event):
# Consume the event stream manually so each event can
# tick the caller's progress callback; get_final_message

View file

@ -1362,10 +1362,32 @@ class AsyncCodexAuxiliaryClient:
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
def __init__(
self,
real_client: Any,
model: str,
is_oauth: bool = False,
base_url: str | None = None,
):
self._client = real_client
self._model = model
self._is_oauth = is_oauth
# Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the
# pre-strip Portal ``.../v1`` form). Only fall back to the SDK
# client's host for Nous Portal — a blanket fallback would flip
# MiniMax/Zhipu/etc. aux adapters from "unknown host = native
# Anthropic" to third-party (stripping thinking signatures).
self._base_url = base_url or None
if not self._base_url:
candidate = str(getattr(real_client, "base_url", "") or "") or None
if candidate:
try:
from agent.anthropic_adapter import _is_nous_portal_endpoint
if _is_nous_portal_endpoint(candidate):
self._base_url = candidate
except Exception:
pass
def create(self, **kwargs) -> Any:
from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message
@ -1417,6 +1439,11 @@ class _AnthropicCompletionsAdapter:
reasoning_config=_reasoning_cfg,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
# Portal routes on ``anthropic/<slug>`` catalog ids and replays
# signed thinking like native Anthropic; both carve-outs key off
# base_url. Omitting it normalizes the id to a bare Anthropic
# slug and the Portal Messages route cannot resolve it.
base_url=self._base_url,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
@ -1510,7 +1537,9 @@ class AnthropicAuxiliaryClient:
def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False):
self._real_client = real_client
adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth)
adapter = _AnthropicCompletionsAdapter(
real_client, model, is_oauth=is_oauth, base_url=base_url,
)
self.chat = _AnthropicChatShim(adapter)
self.api_key = api_key
self.base_url = base_url
@ -2765,7 +2794,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
return None, None
api_key, base_url = resolved
logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model)
real_client = _create_openai_client(api_key=api_key, base_url=base_url)
from tools.xai_http import hermes_xai_default_headers
real_client = _create_openai_client(
api_key=api_key,
base_url=base_url,
default_headers=hermes_xai_default_headers(),
)
return CodexAuxiliaryClient(real_client, model), model
@ -4188,6 +4223,7 @@ def _try_main_agent_model_fallback(
failed_provider: str,
task: str = None,
reason: str = "error",
failed_model: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str], str]:
"""Last-resort fallback to the user's main agent provider + model.
@ -4196,8 +4232,23 @@ def _try_main_agent_model_fallback(
layer: if nothing the user asked for can serve the request, try the
main chat model before giving up.
Skips when the failed provider already IS the main provider (no point
retrying the same backend that just failed).
``failed_model`` narrows the same-provider skip to the exact
(provider, model) pair that just failed, mirroring
:func:`_try_configured_fallback_chain`. This matters for self-hosted /
custom endpoints serving several models behind one provider label: the
aux compression model timing out says nothing about the health of the
main agent model deployed on the same URL (real incident: aux
``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the
identical endpoint was serving 448K-token turns fine the
provider-label skip discarded the one fallback that would have worked).
- Model-specific runtime failures (timeout, connection, rate limit,
model-incompatible, invalid response) pass ``failed_model``: skip the
main model only when it IS the exact model that failed.
- Provider-wide failures (auth 401, payment 402) and legacy callers
leave ``failed_model`` as None, keeping the whole-provider skip
the shared credentials/account are broken, so the main model on the
same provider cannot help either.
Returns:
(client, model, provider_label) or (None, None, "") if no fallback.
@ -4214,9 +4265,23 @@ def _try_main_agent_model_fallback(
if not main_provider or not main_model or main_provider.lower() in {"auto", ""}:
return None, None, ""
skip = (failed_provider or "").lower().strip()
if main_provider.lower() == skip:
# The thing that failed IS the main model — nothing to fall back to.
# Identity + scope semantics owned by agent.backend_identity (#72468):
# model-scoped failures skip only the exact deployment that failed;
# provider-wide failures (no failed_model) skip the credential surface.
from agent.backend_identity import (
BackendIdentity,
FailureScope,
should_skip_candidate,
)
skip_model = (failed_model or "").strip().lower() or None
if should_skip_candidate(
BackendIdentity.build(provider=main_provider, model=main_model),
BackendIdentity.build(provider=failed_provider, model=skip_model),
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL,
):
# The thing that failed IS the main model (or the failure was
# provider-wide) — nothing to fall back to.
return None, None, ""
if _is_provider_unhealthy(main_provider):
_log_skip_unhealthy(main_provider, task)
@ -4326,6 +4391,7 @@ def _try_configured_fallback_chain(
task: str,
failed_provider: str,
reason: str = "error",
failed_model: Optional[str] = None,
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try user-configured fallback_chain for a specific auxiliary task.
@ -4333,6 +4399,25 @@ def _try_configured_fallback_chain(
entry in order. Each entry must have at least ``provider``; ``model``,
``base_url``, and ``api_key`` are optional.
``failed_model`` narrows the skip check to the exact (provider, model)
pair that just failed, rather than the whole provider. Without it every
entry sharing the failed provider is skipped (the original behaviour).
Callers pass it only when a sibling model on the same provider could
plausibly recover:
- Model-specific runtime failures (timeout, connection, rate limit,
model-incompatible, invalid response) pass ``failed_model`` so a
chain that intentionally lists several models under the same provider
e.g. two more NVIDIA NIM models after the primary NIM model times
out is not skipped wholesale. Only the exact model that failed is
skipped; the siblings still run instead of jumping straight to the
main-agent-model safety net.
- Provider-wide failures (auth 401, payment 402) and "no client could
be built" callers leave ``failed_model`` as None, keeping the whole
provider skipped the shared credentials/account behind every model
on that provider are broken, so a sibling can't help and the
main-agent-model safety net should be reached instead.
Returns:
(client, model, provider_label) or (None, None, "") if no fallback.
"""
@ -4344,7 +4429,24 @@ def _try_configured_fallback_chain(
if not chain or not isinstance(chain, list):
return None, None, ""
skip = failed_provider.lower().strip()
skip_model = (failed_model or "").strip().lower() or None
# Identity + scope semantics owned by agent.backend_identity (#59561,
# #72468): a failed_model means the failure was model-scoped (timeout /
# connection / rate limit) — only the exact deployment is skipped; no
# failed_model means provider-wide (auth/payment) — the whole credential
# surface is skipped.
from agent.backend_identity import (
BackendIdentity,
FailureScope,
should_skip_candidate,
)
failed_ident = BackendIdentity.build(
provider=failed_provider, model=skip_model,
)
failure_scope = (
FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL
)
tried = []
min_ctx = _task_minimum_context_length(task)
@ -4352,9 +4454,20 @@ def _try_configured_fallback_chain(
if not isinstance(entry, dict):
continue
fb_provider = str(entry.get("provider", "")).strip()
if not fb_provider or fb_provider.lower() == skip:
if not fb_provider:
continue
fb_model = str(entry.get("model", "")).strip() or None
fb_model_raw = str(entry.get("model", "")).strip()
if should_skip_candidate(
BackendIdentity.build(
provider=fb_provider,
model=fb_model_raw,
base_url=str(entry.get("base_url") or ""),
),
failed_ident,
failure_scope,
):
continue
fb_model = fb_model_raw or None
label = f"fallback_chain[{i}]({fb_provider})"
@ -4785,6 +4898,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"}
elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"):
async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url)
elif base_url_host_matches(sync_base_url, "x.ai"):
from tools.xai_http import hermes_xai_default_headers
async_kwargs["default_headers"] = hermes_xai_default_headers()
else:
# Fall back to profile.default_headers for providers that declare
# client-level headers on their ProviderProfile (e.g. attribution
@ -5021,10 +5138,11 @@ def resolve_provider_client(
# ── Nous Portal (OAuth) ──────────────────────────────────────────
if provider == "nous":
# Detect vision tasks: either explicit model override from
# _PROVIDER_VISION_MODELS, or caller passed a known vision model.
# Detect vision tasks: caller flag (strict vision backend), explicit
# model override from _PROVIDER_VISION_MODELS, or a known vision id.
_is_vision = (
model in _PROVIDER_VISION_MODELS.values()
is_vision
or model in _PROVIDER_VISION_MODELS.values()
or (model or "").strip().lower() == "mimo-v2-omni"
)
client, default = _try_nous(vision=_is_vision)
@ -5033,6 +5151,17 @@ def resolve_provider_client(
"but Nous Portal not configured (run: hermes auth)")
return None, None
final_model = _normalize_resolved_model(model or default, provider)
# Dual-wire: anthropic/* → /v1/messages, everything else stays on
# /chat/completions. Derive from the catalog id (not a stale
# api_mode=chat_completions) so aux matches the main agent.
from hermes_cli.providers import nous_api_mode
portal_mode = nous_api_mode(final_model)
api_key_str = str(getattr(client, "api_key", "") or "")
base_url_str = str(getattr(client, "base_url", "") or "")
client = _maybe_wrap_anthropic(
client, final_model, api_key_str, base_url_str, portal_mode,
)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@ -5396,6 +5525,10 @@ def resolve_provider_client(
))
elif base_url_host_matches(base_url, "integrate.api.nvidia.com"):
headers.update(build_nvidia_nim_headers(base_url))
elif base_url_host_matches(base_url, "x.ai"):
from tools.xai_http import hermes_xai_default_headers
headers.update(hermes_xai_default_headers())
else:
# Fall back to profile.default_headers for providers that declare
# client-level attribution headers on their profile (e.g. GMI
@ -5689,7 +5822,10 @@ def _resolve_strict_vision_backend(
if provider == "openrouter":
return _try_openrouter(model=model)
if provider == "nous":
return _try_nous(vision=True)
# Must go through resolve_provider_client so anthropic/* vision
# recommendations wrap onto /v1/messages — _try_nous alone returns
# a bare OpenAI client and the call 404s.
return resolve_provider_client("nous", model, is_vision=True)
if provider == "openai-codex":
# Route through resolve_provider_client so the caller's explicit
# model is used. There is no safe default Codex model (shifting
@ -6950,8 +7086,14 @@ def _build_call_kwargs(
_is_gemini_native = is_native_gemini_base_url(_effective_base)
except Exception:
pass
_nous_on_messages = False
if _provider_norm in {"nous", "nous-portal", "nousresearch"}:
from hermes_cli.providers import nous_api_mode
_nous_on_messages = nous_api_mode(model) == "anthropic_messages"
if (
_is_anthropic_compat_endpoint(provider, _effective_base)
or _nous_on_messages
or _is_nvidia_nim
or _is_moa
or _is_gemini_native
@ -7046,21 +7188,43 @@ def _build_call_kwargs(
else:
effort = reasoning_config.get("effort") or "medium"
merged_extra["reasoning"] = {"enabled": True, "effort": effort}
if provider == "nous" and "tags" not in merged_extra:
merged_extra["tags"] = _nous_portal_tags()
# Portal product tags + sticky session_id. The provider profile usually
# supplies both; this fallback covers profile-load failures and alias
# spellings the profile lookup might miss. session_id keeps aux
# compression/title/vision calls on the same upstream instance as the
# main turn (cache warmth) — tags alone are not enough on /v1/messages.
_provider_for_portal = str(provider or "").strip().lower()
if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}:
if "tags" not in merged_extra:
merged_extra["tags"] = _nous_portal_tags()
if "session_id" not in merged_extra:
try:
from agent.portal_tags import get_conversation_context
sticky_key = get_conversation_context()
except Exception:
sticky_key = None
if sticky_key:
merged_extra["session_id"] = sticky_key
if merged_extra:
kwargs["extra_body"] = merged_extra
# Native Anthropic Messages adapters do not consume ``extra_body``. Carry
# the normalized Hermes reasoning config through a private kwarg so the
# adapter can pass it into build_anthropic_kwargs(), where provider-aware
# thinking/output_config projection lives. Do not expose this private kwarg
# to ordinary OpenAI-compatible SDK clients, which would reject it.
# Anthropic Messages adapters translate Hermes reasoning into native
# ``thinking`` via a private kwarg (and strip OpenAI-shaped
# ``extra_body.reasoning``). Do not expose this private kwarg to ordinary
# OpenAI-compatible SDK clients, which would reject it. Portal Claude is
# dual-wire — include it when the catalog id selects /v1/messages.
if reasoning_config and isinstance(reasoning_config, dict):
provider_norm = str(provider or "").strip().lower()
effective_base = base_url or ""
_nous_on_messages = False
if provider_norm in {"nous", "nous-portal", "nousresearch"}:
from hermes_cli.providers import nous_api_mode
_nous_on_messages = nous_api_mode(model) == "anthropic_messages"
if (
provider_norm == "anthropic"
or _nous_on_messages
or _endpoint_speaks_anthropic_messages(effective_base)
or _is_anthropic_compat_endpoint(provider_norm, effective_base)
):
@ -8078,6 +8242,15 @@ def call_llm(
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
# Narrow the configured-chain skip to the exact model that
# failed ONLY for model-specific failures. Auth (401) and
# payment (402) errors are provider-wide — the credentials or
# account behind every model on that provider are the same — so
# a sibling model can't recover; keep skipping the whole
# provider so the main-agent-model safety net is still reached.
_chain_failed_model = (
None if reason in ("auth error", "payment error") else final_model
)
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. For auto: top-level main fallback_providers/fallback_model
@ -8086,7 +8259,8 @@ def call_llm(
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
task, resolved_provider or "auto", reason=reason,
failed_model=_chain_failed_model)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@ -8095,10 +8269,12 @@ def call_llm(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
task, resolved_provider or "auto", reason=reason,
failed_model=_chain_failed_model)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
resolved_provider, task, reason=reason)
resolved_provider, task, reason=reason,
failed_model=_chain_failed_model)
if fb_client is not None:
fb_resp = _call_fallback_candidate_sync(
@ -8612,6 +8788,15 @@ async def async_call_llm(
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
# Narrow the configured-chain skip to the exact model that
# failed ONLY for model-specific failures. Auth (401) and
# payment (402) errors are provider-wide — the credentials or
# account behind every model on that provider are the same — so
# a sibling model can't recover; keep skipping the whole
# provider so the main-agent-model safety net is still reached.
_chain_failed_model = (
None if reason in ("auth error", "payment error") else final_model
)
# Fallback order (#26882, #26803):
# 1. User-configured fallback_chain (per-task) if set
# 2. For auto: top-level main fallback_providers/fallback_model
@ -8620,7 +8805,8 @@ async def async_call_llm(
fb_client, fb_model, fb_label = (None, None, "")
if is_auto:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
task, resolved_provider or "auto", reason=reason,
failed_model=_chain_failed_model)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_fallback_chain(
task, resolved_provider or "auto", reason=reason)
@ -8629,10 +8815,12 @@ async def async_call_llm(
resolved_provider, task, reason=reason)
else:
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
task, resolved_provider or "auto", reason=reason)
task, resolved_provider or "auto", reason=reason,
failed_model=_chain_failed_model)
if fb_client is None:
fb_client, fb_model, fb_label = _try_main_agent_model_fallback(
resolved_provider, task, reason=reason)
resolved_provider, task, reason=reason,
failed_model=_chain_failed_model)
if fb_client is not None:
# Convert sync fallback client to async

204
agent/backend_identity.py Normal file
View file

@ -0,0 +1,204 @@
"""Single owner for backend identity and failure-scoped skip decisions.
Every fallback / dedup / skip / quarantine decision in Hermes ultimately asks
one question: **"is this candidate the same backend as the one that failed,
along the axis that failure invalidated?"** Before this module, that
question was re-implemented inline at six call sites across four subsystems,
each comparing whatever string was locally convenient (provider label,
provider+model, base_url+model, ...). Each incident fixed one site while the
others kept the bug: #22548 (same-shim aliases), #70893 (xai-oauth vs xai —
same host, distinct credential), #59561 (aux chain skipped sibling models),
#72468 (aux main-model safety net, same bug three weeks later), #62984 /
#54250 / #57584 (dedup ignoring base_url strands multi-endpoint pools).
The root insight: "provider" conflates three independent identity axes, and
each failure class invalidates a different one:
* **credential surface** auth 401 / payment 402 kill everything sharing the
credential (every model, every host reached with that key/token).
* **endpoint** DNS failure / connection refused kill everything behind the
URL, regardless of model or credential.
* **model deployment** timeout / overload / rate limit / model-incompatible
kill ONE model's deployment. A sibling model behind the same URL is an
independent deployment (real incident: aux ``glm-5.2`` hung and timed out
while main ``macaron-v1-venti`` on the identical endpoint was serving
448K-token turns).
Call sites should build :class:`BackendIdentity` values, classify the failure
with :func:`classify_failure_scope`, and ask :func:`should_skip_candidate`.
Do not re-implement any comparison inline extend THIS module instead.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
class FailureScope(Enum):
"""Which identity axis a failure invalidates."""
#: Timeout, overload/429, connection blip, model-incompatible, invalid
#: response: evidence against ONE model deployment only.
MODEL = "model"
#: Auth 401 / payment 402: evidence against the shared credential —
#: every model reached with it is equally dead.
CREDENTIAL = "credential"
#: DNS / connection-refused / unreachable host: evidence against the
#: endpoint — every model behind the URL is equally dead.
ENDPOINT = "endpoint"
#: Reason strings already used by auxiliary_client's except-chain, mapped to
#: scopes. Unknown reasons default to MODEL — the least-invalidating scope —
#: so an unrecognized failure never over-skips viable candidates.
_REASON_SCOPES = {
"auth error": FailureScope.CREDENTIAL,
"payment error": FailureScope.CREDENTIAL,
"rate limit": FailureScope.MODEL,
"model incompatible with route": FailureScope.MODEL,
"invalid provider response": FailureScope.MODEL,
"connection error": FailureScope.MODEL,
"timeout": FailureScope.MODEL,
}
def classify_failure_scope(reason: Optional[str]) -> FailureScope:
"""Map a human-readable failure reason to the identity axis it kills."""
return _REASON_SCOPES.get((reason or "").strip().lower(), FailureScope.MODEL)
def _norm_provider(value: Optional[str]) -> str:
return (value or "").strip().lower()
def _norm_model(value: Optional[str]) -> str:
return (value or "").strip().lower()
def _norm_base_url(value: Optional[str]) -> str:
return (value or "").strip().rstrip("/").lower()
@dataclass(frozen=True)
class BackendIdentity:
"""Normalized identity of one (provider, model, endpoint) deployment.
Empty fields mean "unknown" comparisons treat an unknown axis as
non-distinguishing (it can neither prove sameness nor difference on its
own; the remaining axes decide).
"""
provider: str = ""
model: str = ""
base_url: str = ""
@classmethod
def build(
cls,
provider: Optional[str] = None,
model: Optional[str] = None,
base_url: Optional[str] = None,
) -> "BackendIdentity":
return cls(
provider=_norm_provider(provider),
model=_norm_model(model),
base_url=_norm_base_url(base_url),
)
def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool:
"""True when both providers are distinct registered first-class providers.
Two different registry providers have distinct credential surfaces even
when they share an inference host (xai-oauth vs xai, openai-codex vs
openai-api) #70893. Custom/shim aliases are NOT in the registry, so
two aliases pointing at one URL still count as the same backend (#22548).
"""
if not a.provider or not b.provider or a.provider == b.provider:
return False
try:
from hermes_cli.auth import PROVIDER_REGISTRY
return a.provider in PROVIDER_REGISTRY and b.provider in PROVIDER_REGISTRY
except Exception:
return False
def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Do two identities share the credential a 401/402 just invalidated?
Conservative on purpose: an unprovable axis must answer "different"
(try the candidate worst case one wasted RTT) rather than "same"
(skip worst case stranded failover). Two distinct custom labels at
one URL may carry different per-entry api_keys, so a shared URL alone
never proves a shared credential; it is only used as a weak signal
when a provider label is missing entirely.
"""
if a.provider and b.provider:
# Same label = same configured credential. Different labels =
# different credential config (first-class registry providers
# explicitly so — #70893; custom entries can each carry their own
# api_key, so sameness is unprovable and we must not skip).
return a.provider == b.provider
# Provider unknown on a side: same explicit URL is the best signal left.
return bool(a.base_url and a.base_url == b.base_url)
def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Do two identities sit behind the endpoint that just went unreachable?"""
if a.base_url and b.base_url:
return a.base_url == b.base_url
# An unknown base_url inherits the provider default → same provider
# label implies the same default endpoint.
return bool(a.provider and a.provider == b.provider)
def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool:
"""Are these the exact same model deployment (the thing a timeout kills)?
Provider+model must match; the base_url axis distinguishes only when BOTH
sides carry an explicit URL (#62984: same provider+model on two different
explicit URLs is two deployments a pool). A side with an unknown URL
inherits the provider default and cannot prove difference.
"""
if not (a.provider and b.provider and a.provider == b.provider):
# Same-host different-label shims: same URL + same model IS the same
# deployment even when the alias labels differ (#22548) — unless both
# labels are first-class registry providers (#70893).
if (
a.base_url
and a.base_url == b.base_url
and a.model
and a.model == b.model
and not _both_first_class(a, b)
):
return True
return False
if not (a.model and b.model and a.model == b.model):
return False
if a.base_url and b.base_url and a.base_url != b.base_url:
return False # distinct explicit endpoints — a pool, not a dup
return True
def should_skip_candidate(
candidate: BackendIdentity,
failed: BackendIdentity,
scope: FailureScope = FailureScope.MODEL,
) -> bool:
"""THE skip predicate: would trying ``candidate`` just repeat the failure?
True when the candidate is the same backend as ``failed`` along the axis
``scope`` says the failure invalidated. Every fallback/dedup/skip site
must call this instead of comparing labels inline.
"""
if scope is FailureScope.CREDENTIAL:
return same_credential_surface(candidate, failed)
if scope is FailureScope.ENDPOINT:
return same_endpoint(candidate, failed)
return same_deployment(candidate, failed)

View file

@ -107,6 +107,22 @@ class CardInfo:
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class PaymentMethodInfo:
"""The payment method on file. `kind` is "card", "link", or "unknown"
anything else is normalised to "unknown" at parse time, so consumers
only ever see fields that belong to the kind they are looking at."""
kind: str
brand: Optional[str] = None
last4: Optional[str] = None
wallet: Optional[str] = None
email: Optional[str] = None
resolved_via: Optional[str] = None
#: What the server called it, when we did not recognise the kind.
raw_kind: Optional[str] = None
@dataclass(frozen=True)
class MonthlyCap:
limit_usd: Optional[Decimal] = None
@ -150,6 +166,7 @@ class BillingState:
min_usd: Optional[Decimal] = None
max_usd: Optional[Decimal] = None
card: Optional[CardInfo] = None
payment_method: Optional[PaymentMethodInfo] = None
monthly_cap: Optional[MonthlyCap] = None
auto_reload: Optional[AutoReload] = None
portal_url: Optional[str] = None
@ -201,6 +218,41 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
def _parse_payment_method(raw: Any) -> Optional[PaymentMethodInfo]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if not isinstance(kind, str):
return None
def _optional_string(key: str) -> Optional[str]:
value = raw.get(key)
return value if isinstance(value, str) else None
resolved_via = _optional_string("resolvedVia")
brand = _optional_string("brand")
last4 = _optional_string("last4")
# Settle the kind here, the way _parse_card settles a card, so nothing
# downstream has to re-check which fields this kind is allowed to have.
if kind == "card" and brand and last4:
return PaymentMethodInfo(
kind="card",
brand=brand,
last4=last4,
wallet=_optional_string("wallet"),
resolved_via=resolved_via,
)
if kind == "link":
return PaymentMethodInfo(
kind="link",
email=_optional_string("email"),
resolved_via=resolved_via,
)
return PaymentMethodInfo(
kind="unknown", raw_kind=kind, resolved_via=resolved_via
)
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
if not isinstance(raw, dict):
return None
@ -274,6 +326,7 @@ def billing_state_from_payload(
min_usd=parse_money(bounds.get("minUsd")),
max_usd=parse_money(bounds.get("maxUsd")),
card=_parse_card(payload.get("card")),
payment_method=_parse_payment_method(payload.get("paymentMethod")),
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
auto_reload=_parse_auto_reload(payload.get("autoReload")),
portal_url=portal_url,

View file

@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
return preferences
def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict:
"""Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict.
The Nous provider profile is only consulted by the OpenAI-wire transport;
anthropic_messages callers must merge it themselves. Passes ``session_id``
only not ``provider_preferences`` (those become a top-level ``provider``
routing object on the OpenAI wire). Never blocks a turn on tagging.
"""
if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}:
return anthropic_kwargs
try:
from providers import get_provider_profile
nous_profile = get_provider_profile("nous")
if nous_profile is not None:
anthropic_kwargs.setdefault("extra_body", {}).update(
nous_profile.build_extra_body(
session_id=getattr(agent, "session_id", None)
)
)
except Exception as exc: # noqa: BLE001 — never block a turn on tagging
logger.debug("Nous Portal extra_body merge failed: %s", exc)
return anthropic_kwargs
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name, str(default)))
@ -433,26 +458,56 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
def should_use_direct_api_call(agent) -> bool:
"""Whether a cron OpenAI-wire request should skip the interrupt worker.
"""Whether an OpenAI-wire request should skip the interrupt worker.
Issue #62151 is specific to OpenRouter's chat-completions path inside the
gateway cron thread stack. Keep native/Codex/Bedrock/MoA transports on their
established workers: their cancellation and client ownership differ, and
the report provides no evidence that those paths share the pre-HTTP wedge.
Two nested-pool contexts wedge before the socket opens when the request
is pushed onto yet another daemon worker thread:
- Gateway cron turns (#62151): gateway asyncio loop → cron thread →
interrupt worker. Fixed by running inline.
- Delegated children (#60203): gateway loop → async-delegation executor
(module-lifetime daemon pool) per-child timeout executor interrupt
worker. Same fingerprint after multi-day gateway uptime children hang
at their FIRST API call with zero stale-detector output (the worker
never reaches dispatch), all providers, restart cures it. The cron fix
originally excluded delegation "for lack of evidence"; #60203 is that
evidence.
Running inline drops the deepest thread layer (whose only job is
interactive-interrupt responsiveness). Interrupts still work: the inline
path registers ``agent._active_request_abort``, which ``interrupt()``
invokes cross-thread to shut the active sockets the same mechanism the
async-delegation stall monitor (#72227) relies on.
Keep native/Codex/Bedrock/MoA transports on their established workers:
their cancellation and client ownership differ.
"""
return (
getattr(agent, "platform", None) == "cron"
and getattr(agent, "api_mode", None) == "chat_completions"
and getattr(agent, "provider", None) != "moa"
)
if getattr(agent, "api_mode", None) != "chat_completions":
return False
if getattr(agent, "provider", None) == "moa":
return False
if getattr(agent, "platform", None) == "cron":
return True
# Delegated child (delegate_task sync or background) — detected via the
# execution ContextVar set by _run_single_child, with the agent's own
# platform stamp as a fallback for callers that bypass the runner.
try:
from agent.delegation_context import is_delegated_child_context
if is_delegated_child_context():
return True
except Exception:
pass
return getattr(agent, "platform", None) == "subagent"
def direct_api_call(agent, api_kwargs: dict):
"""Run a non-streaming LLM call inline on the conversation thread.
Used when ``should_use_direct_api_call`` is True. Skips the interrupt worker
(whose only job is interactive-interrupt responsiveness, which this context
does not have) so the nested-pool deadlock (#62151) cannot occur. Because the
Used when ``should_use_direct_api_call`` is True (cron turns and
delegated children). Skips the interrupt worker (whose only job is
interactive-interrupt responsiveness, which these contexts do not have)
so the nested-pool deadlock (#62151, #60203) cannot occur. Because the
request runs in-flight normally, the per-request OpenAI client's own httpx
timeout (provider ``request_timeout_seconds`` / ``HERMES_API_TIMEOUT``) bounds
a genuinely hung provider the same bound interactive calls already rely on.
@ -463,7 +518,7 @@ def direct_api_call(agent, api_kwargs: dict):
request_client_lock = threading.Lock()
def _abort_active_request(reason: str) -> None:
"""Abort the inline request from cron's watchdog/interrupt thread."""
"""Abort the inline request from a watchdog/interrupt thread."""
with request_client_lock:
request_client = request_client_holder["client"]
if request_client is not None:
@ -993,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
if ephemeral_out is not None:
agent._ephemeral_max_output_tokens = None # consume immediately
return _transport.build_kwargs(
anthropic_kwargs = _transport.build_kwargs(
model=agent.model,
messages=anthropic_messages,
tools=tools_for_api,
@ -1006,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
fast_mode=(agent.request_overrides or {}).get("speed") == "fast",
drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)),
)
# Nous Portal reads ``tags`` and ``session_id`` as top-level body fields
# on its Messages route the same way it does on /chat/completions, but
# the profile hook that produces them is only consulted by the
# OpenAI-wire transport. Merge them here so Messages traffic keeps
# product attribution and sticky routing.
return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
# The adapter handles message/tool conversion and boto3 calls directly.
@ -1312,6 +1373,17 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
from agent.redact import redact_sensitive_text
_san_content = redact_sensitive_text(_san_content)
# NOTE (empty-content class fix): textless assistant turns are NOT padded
# here. The single owner for "never send a turn strict wire validation
# rejects as empty" is ``repair_empty_non_final_messages`` in
# agent_runtime_helpers, which runs inside ``sanitize_api_messages`` — the
# unconditional pre-send chokepoint for both the main loop and the summary
# path. Padding at write time was tried (a single-space pad, later a
# placeholder) and rejected: it forked the concept across three sites,
# broke codex commentary turns (content:'' is a designed state there), and
# a DB-side pad can't survive ``_rows_to_conversation``'s whitespace strip
# anyway. Repair belongs at the send boundary, once.
msg = {
"role": "assistant",
"content": _san_content,
@ -1514,45 +1586,6 @@ def _fallback_entry_key(fb: dict) -> tuple[str, str, str]:
)
def _fallback_entry_is_same_backend_by_base_url(
*,
current_provider: str,
fb_provider: str,
current_base_url: str,
fb_base_url: str,
current_model: str,
fb_model: str,
) -> bool:
"""True when base_url+model identity means the fallback is the same backend.
Issue #22548: two ``custom_providers`` aliases that point at the same shim
URL with the same model must be skipped, or failover loops on the dead
backend. First-class providers that share a host while using different
auth (``xai-oauth`` vs ``xai``, ``openai-codex`` vs ``openai-api``) are
distinct credential surfaces skipping them strands configured failover
when primary and fallback reuse the same model slug on that host.
"""
if not (
fb_base_url
and current_base_url
and fb_base_url == current_base_url
and fb_model == current_model
):
return False
if fb_provider == current_provider:
return True
try:
from hermes_cli.auth import PROVIDER_REGISTRY
# Both sides are registered first-class providers → different auth
# identities even when the inference host matches. Allow failover.
if current_provider in PROVIDER_REGISTRY and fb_provider in PROVIDER_REGISTRY:
return False
except Exception:
pass
return True
def _fallback_entry_unavailable_without_network(agent, fb: dict) -> Optional[str]:
"""Return a skip reason for fallback entries known to be unusable locally."""
fb_provider = (fb.get("provider") or "").strip().lower()
@ -1638,33 +1671,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
)
return agent._try_activate_fallback(reason)
# Skip entries that resolve to the current (provider, model) — falling
# back to the same backend that just failed loops the failure. Compare
# base_url too so two distinct custom_providers entries pointing at the
# same shim/proxy URL also dedup. See issue #22548. Do NOT treat
# first-class providers that share a host (xai-oauth vs xai) as the same
# backend — they use different credentials.
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
current_model = (getattr(agent, "model", "") or "").strip()
current_base_url = str(getattr(agent, "base_url", "") or "").rstrip("/").lower()
fb_base_url_for_dedup = (fb.get("base_url") or "").strip().rstrip("/").lower()
if fb_provider == current_provider and fb_model == current_model:
# Skip entries that resolve to the same backend that just failed —
# falling back to it loops the failure. Identity semantics (which axes
# distinguish two backends, shim aliases, first-class credential
# surfaces, multi-endpoint pools) are owned by agent.backend_identity —
# see #22548, #70893, #62984. Do not re-implement comparisons here.
from agent.backend_identity import BackendIdentity, should_skip_candidate
current_ident = BackendIdentity.build(
provider=getattr(agent, "provider", ""),
model=getattr(agent, "model", ""),
base_url=str(getattr(agent, "base_url", "") or ""),
)
fb_ident = BackendIdentity.build(
provider=fb_provider,
model=fb_model,
base_url=(fb.get("base_url") or ""),
)
if should_skip_candidate(fb_ident, current_ident):
logger.warning(
"Fallback skip: chain entry %s/%s matches current provider/model",
fb_provider, fb_model,
)
return agent._try_activate_fallback(reason)
if _fallback_entry_is_same_backend_by_base_url(
current_provider=current_provider,
fb_provider=fb_provider,
current_base_url=current_base_url,
fb_base_url=fb_base_url_for_dedup,
current_model=current_model,
fb_model=fb_model,
):
logger.warning(
"Fallback skip: chain entry base_url %s matches current backend",
fb_base_url_for_dedup,
"Fallback skip: chain entry %s/%s resolves to the same backend "
"as the current one (%s)",
fb_provider, fb_model, current_ident.base_url or current_ident.provider,
)
return agent._try_activate_fallback(reason)
@ -1715,6 +1743,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
_fb_is_azure = agent._is_azure_openai_url(fb_base_url)
if fb_provider == "openai-codex":
fb_api_mode = "codex_responses"
elif fb_provider in {"nous", "nous-portal", "nousresearch"}:
# Portal is dual-wire: anthropic/* must land on /v1/messages.
# resolve_provider_client still returns an OpenAI client for
# Nous; the anthropic_messages branch below rebuilds the native
# client from that credential + base_url.
from hermes_cli.providers import nous_api_mode
fb_api_mode = nous_api_mode(fb_model)
elif (
fb_provider == "anthropic"
or fb_base_url.rstrip("/").lower().endswith("/anthropic")
@ -2141,7 +2177,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
_ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None,
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
is_oauth=agent._is_anthropic_oauth,
preserve_dots=agent._anthropic_preserve_dots())
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None))
_ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw)
summary_response = agent._anthropic_messages_create(_ant_kw)
_summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth)
final_response = (_summary_result.content or "").strip()
@ -2171,7 +2209,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
_ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None,
is_oauth=agent._is_anthropic_oauth,
max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config,
preserve_dots=agent._anthropic_preserve_dots())
preserve_dots=agent._anthropic_preserve_dots(),
base_url=getattr(agent, "_anthropic_base_url", None))
_ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2)
retry_response = agent._anthropic_messages_create(_ant_kw2)
_retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth)
final_response = (_retry_result.content or "").strip()
@ -3889,6 +3929,17 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["error"],
)
_stub_finish_reason = FINISH_REASON_LENGTH
# NOTE (empty-content class fix): the stub is deliberately allowed
# to carry empty content here. The conversation loop's truncation
# path detects an EMPTY partial-stream stub (PARTIAL_STREAM_STUB_ID
# + no content) and skips appending it to history entirely — only
# the continuation nudge is sent. Substituting placeholder text at
# this site was tried and reverted: it defeats that guard (the stub
# no longer looks empty), gets appended to history, and the
# placeholder leaks into the stitched final response via
# truncated_response_parts. Transcripts that already carry a
# persisted empty turn are healed at the send boundary by
# ``repair_empty_non_final_messages`` (the single owner).
_stub_msg = SimpleNamespace(
role="assistant", content=_partial_text, tool_calls=None,
reasoning_content=None,

View file

@ -778,12 +778,27 @@ def run_codex_app_server_turn(
# the already-flushed user turn). See gateway/run.py agent_persisted.
if getattr(agent, "_session_db", None) is not None:
try:
agent._flush_messages_to_session_db(messages)
_codex_flush_ok = agent._flush_messages_to_session_db(messages)
except Exception:
logger.debug(
_codex_flush_ok = False
logger.warning(
"codex app-server projected-message flush failed",
exc_info=True,
)
if _codex_flush_ok is False:
# Unlike the chat-completions loop (which fails closed BEFORE
# projection — see conversation_loop session_persistence_failed),
# codex output has already streamed to the user by the time this
# flush runs, so there is nothing left to withhold. We cannot
# flip agent_persisted=False either: the gateway fallback write
# would re-INSERT the already-flushed user turn (#860/#42039).
# Surface the durability gap loudly instead of a silent debug.
logger.warning(
"codex app-server turn was delivered but could NOT be "
"persisted to the session DB (session=%s) — this turn "
"will be missing after restart/resume",
getattr(agent, "session_id", None),
)
# Counter ticks for the agent-improvement loop.

View file

@ -154,3 +154,207 @@ def compute_session_context_breakdown(
"estimated_total": estimated_total,
"model": getattr(agent, "model", "") or "",
}
# ── /context rendering (CLI + gateway) ──────────────────────────────────────
#
# Pure text renderers over the payload above. The CLI shows a glyph block-grid
# plus a category table; the gateway uses the same table without the grid
# (proportional monospace is not guaranteed on messaging platforms).
_CATEGORY_GLYPHS = {
"system_prompt": "",
"tool_definitions": "",
"rules": "",
"skills": "",
"mcp": "",
"subagent_definitions": "",
"memory": "",
"conversation": "",
}
_FREE_GLYPH = "·"
_GRID_COLUMNS = 20
_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window
# Human-readable tables cap the expanded listings; nothing is dropped from
# the underlying data.
_DETAILS_TABLE_LIMIT = 15
def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
if size is None:
return None
return (int(size) + 3) // 4
def compute_context_details(agent: Any) -> Dict[str, Any]:
"""Expanded per-skill / per-toolset cost listing for ``/context all``.
Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656):
per-skill index-line bytes parsed from the live ``<available_skills>``
block, and per-toolset schema bytes attributed via the tool registry's
canonical tooltoolset map. Byte figures are converted to the same
chars/4 token heuristic the categories above use.
"""
from hermes_cli.prompt_size import (
_compute_skills_breakdown,
_compute_toolsets_breakdown,
)
from agent.system_prompt import build_system_prompt_parts
parts = build_system_prompt_parts(agent)
stable = parts.get("stable", "") or ""
skills_match = _SKILLS_BLOCK_RE.search(stable)
skills_block = skills_match.group(0) if skills_match else ""
skills: List[Dict[str, Any]] = []
if skills_block:
for entry in _compute_skills_breakdown(skills_block):
skills.append({
"name": entry.get("name", ""),
"index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0,
"skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")),
})
toolsets: List[Dict[str, Any]] = []
tools = list(getattr(agent, "tools", None) or [])
if tools:
for group in _compute_toolsets_breakdown(tools):
toolsets.append({
"toolset": group.get("toolset", ""),
"tool_count": int(group.get("tool_count", 0) or 0),
"schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0,
})
return {"skills": skills, "toolsets": toolsets}
def render_context_grid(payload: Dict[str, Any]) -> List[str]:
"""Render the payload as a Claude Code-style glyph block grid.
100 cells (5×20), each one percent of the model context window. Categories
fill in declaration order; the remainder renders as free space.
"""
context_max = int(payload.get("context_max") or 0)
categories = payload.get("categories") or []
total_cells = _GRID_COLUMNS * _GRID_ROWS
cells: List[str] = []
if context_max > 0:
for cat in categories:
tokens = int(cat.get("tokens") or 0)
n = round(tokens / context_max * total_cells)
if tokens > 0 and n == 0:
n = 1 # never render a nonzero category as invisible
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "")
cells.extend([glyph] * n)
cells = cells[:total_cells]
cells.extend([_FREE_GLYPH] * (total_cells - len(cells)))
return [
" ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS])
for row in range(_GRID_ROWS)
]
def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
"""Render the 'Estimated usage by category' table as plain-text lines."""
categories = payload.get("categories") or []
context_max = int(payload.get("context_max") or 0)
estimated_total = int(payload.get("estimated_total") or 0)
denom = context_max or estimated_total
lines = ["Estimated usage by category"]
if not categories:
lines.append(" (no data yet — send a message first)")
return lines
width = max(len(str(cat.get("label") or "")) for cat in categories)
width = max(width, len("Free space"))
for cat in categories:
tokens = int(cat.get("tokens") or 0)
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "")
pct = tokens / denom * 100 if denom else 0.0
label = str(cat.get("label") or cat.get("id") or "")
lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%")
if context_max > 0:
free = max(0, context_max - estimated_total)
pct = free / context_max * 100
lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%")
return lines
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
lines: List[str] = []
toolsets = details.get("toolsets") or []
if toolsets:
lines.append("Toolsets by schema cost (largest first)")
for group in toolsets[:_DETAILS_TABLE_LIMIT]:
lines.append(
f" {group['toolset']:<24} {group['tool_count']:>3} tools"
f" {group['schema_tokens']:>8,} tokens"
)
remaining = len(toolsets) - _DETAILS_TABLE_LIMIT
if remaining > 0:
lines.append(f" … and {remaining} more")
skills = details.get("skills") or []
if skills:
if lines:
lines.append("")
lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)")
for entry in skills[:_DETAILS_TABLE_LIMIT]:
name = str(entry.get("name") or "")
if len(name) > 28:
name = name[:27] + ""
md = entry.get("skill_md_tokens")
md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}"
lines.append(
f" {name:<28} index {entry['index_tokens']:>6,}"
f" SKILL.md {md_str} tokens"
)
remaining = len(skills) - _DETAILS_TABLE_LIMIT
if remaining > 0:
lines.append(f" … and {remaining} more")
return lines
def render_context_breakdown_lines(
payload: Dict[str, Any],
*,
details: Optional[Dict[str, Any]] = None,
grid: bool = True,
) -> List[str]:
"""Render the full /context view as plain-text lines.
``grid=True`` (CLI) prepends the glyph block grid; the gateway passes
``grid=False`` and keeps its own gauge. ``details`` (from
:func:`compute_context_details`) appends the expanded listings.
"""
lines: List[str] = []
if grid:
lines.extend(render_context_grid(payload))
lines.append("")
lines.extend(render_context_category_lines(payload))
context_max = int(payload.get("context_max") or 0)
context_used = int(payload.get("context_used") or 0)
if context_max > 0:
pct = int(payload.get("context_percent") or 0)
lines.append("")
lines.append(
f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)"
)
if details is not None:
detail_lines = render_context_details_lines(details)
if detail_lines:
lines.append("")
lines.extend(detail_lines)
else:
lines.append("")
lines.append("Use /context all for per-skill and per-toolset costs.")
return lines

View file

@ -213,8 +213,12 @@ async def preprocess_context_references_async(
f"@ context injection warning: {injected_tokens} tokens exceeds the 25% soft limit ({soft_limit})."
)
stripped = _remove_reference_tokens(message, refs)
final = stripped
# Leave the `@file:`/`@folder:` tokens where the user typed them. The token
# IS the reference, not scaffolding around it: clients render each one as an
# inline chip, so stripping them left a sentence with a hole in it ("review
# and ship") and made the desktop re-derive the refs from the attached block
# to show them as a detached list above the prose.
final = message
if warnings:
final = f"{final}\n\n--- Context Warnings ---\n" + "\n".join(f"- {warning}" for warning in warnings)
if blocks:
@ -473,19 +477,6 @@ def _parse_file_reference_value(value: str) -> tuple[str, int | None, int | None
return _strip_reference_wrappers(value), None, None
def _remove_reference_tokens(message: str, refs: list[ContextReference]) -> str:
pieces: list[str] = []
cursor = 0
for ref in refs:
pieces.append(message[cursor:ref.start])
cursor = ref.end
pieces.append(message[cursor:])
text = "".join(pieces)
text = re.sub(r"\s{2,}", " ", text)
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
return text.strip()
def _is_binary_file(path: Path) -> bool:
mime, _ = mimetypes.guess_type(path.name)
if mime and not mime.startswith("text/") and not any(

View file

@ -1402,7 +1402,10 @@ def compress_context(
# `name #N` renumber, no contextvar/env/logging re-sync, no memory/context-
# engine session-switch. The conversation keeps one durable id for life,
# eliminating the session-rotation bug cluster. Default True (2107b86024).
in_place = bool(getattr(agent, "compression_in_place", False))
# Default True matches DEFAULT_CONFIG / #38763. A missing attribute must
# NOT fall back to rotation mode — that re-enables the pre-lease drift
# path and can wedge busy sessions that never set the flag.
in_place = bool(getattr(agent, "compression_in_place", True))
# Set True once the in-place DB write actually completes (the DB block can
# raise and skip it). Surfaced to the gateway via agent._last_compaction_in_place.
compacted_in_place = False
@ -1712,6 +1715,13 @@ def compress_context(
# non-destructive — pre-compaction rows are soft-archived (active=0,
# compacted=1), stay searchable and recoverable, so snapshot/durable
# drift cannot lose data there and must not abort compaction.
#
# When durable DID grow, ADOPT it and continue rather than aborting.
# Aborting returned the stale snapshot unchanged, so busy sessions
# (memory review / shared session_id writers) stayed permanently
# behind the DB: every /compress and auto-compress saw
# "changed before lease acquisition", surfaced as the misleading
# "No changes from compression", and never reclaimed tokens.
if not in_place and _lock_db is not None and _lock_sid:
durable_loader = getattr(
type(_lock_db), "get_messages_as_conversation", None
@ -1719,16 +1729,19 @@ def compress_context(
if callable(durable_loader):
durable_parent = durable_loader(_lock_db, _lock_sid)
if isinstance(durable_parent, list) and len(durable_parent) > len(messages):
logger.warning(
"compression aborted: session=%s changed before lease "
"acquisition; preserving newer durable messages",
logger.info(
"compression: session=%s grew before lease "
"(%d%d msgs); adopting durable snapshot",
_lock_sid,
len(messages),
len(durable_parent),
)
_release_lock()
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
messages = durable_parent
_pre_msg_count = len(messages)
# Token estimate was for the stale snapshot; clear it so
# the compressor re-derives from the adopted transcript
# instead of under-counting the newly visible rows.
approx_tokens = 0
# Notify external memory provider before compression discards context.
# The provider's on_pre_compress() may return a string of insights it
@ -2027,14 +2040,13 @@ def compress_context(
# (same startswith gate as the restore path); otherwise the
# request layer falls back to the legacy single-breakpoint
# layout with the prompt bytes untouched.
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
from agent.system_prompt import reconstruct_static_prefix
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and cached_system_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
pass
reconstruct_static_prefix(
agent,
system_message=system_message,
log_label="compression keep-prompt",
)
else:
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt

View file

@ -72,7 +72,10 @@ from agent.model_metadata import (
save_context_length,
)
from agent.process_bootstrap import _install_safe_stdio
from agent.prompt_caching import apply_anthropic_cache_control
from agent.prompt_caching import (
apply_anthropic_cache_control,
strip_anthropic_cache_control,
)
from agent.retry_utils import (
adaptive_rate_limit_backoff,
is_zai_coding_overload_error,
@ -118,22 +121,44 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
Incomplete provider reasoning blocks are not valid replay items (Anthropic
signs them; Responses reasoning items require their following output).
Preserve only what Hermes actually displayed, demoted to ordinary text,
then add the correction as a real user message. This keeps role alternation
Preserve only the *visible* response text, demoted to ordinary text, then
add the correction as a real user message. This keeps role alternation
valid and leaves every previously cached message byte-for-byte unchanged.
INVARIANT raw chain-of-thought must never be serialized into replayable
message content. Streamed reasoning is display-only state: it may be shown
live, but it does not re-enter the transcript as assistant (or user) text.
An assistant turn whose content inlines its own chain-of-thought reads to
Anthropic's output classifier as reasoning-injection/prefill jailbreak,
and because the poisoned checkpoint is persisted and replayed on every
subsequent call, the session dies permanently with deterministic
"Provider returned an empty response" storms that no retry, nudge, or
empty-recovery branch can escape (July 2026: four sessions bricked this
way; every reasoning-free checkpoint that week was untouched same
mechanism as the ~/.hermes/prefill.json incident, 20/20 blocked with
assistant-exposed CoT vs 0/20 without). The interrupted reasoning was
incomplete by definition; the model regenerates it on the retried turn.
If a future path needs to preserve interrupted thinking, carry it in a
provider-gated reasoning *field*, never in content.
INVARIANT the scaffolding is provider-replay text, not transcript text.
``[This response was interrupted by a user correction.]`` and its
``Visible response before the interruption:`` header exist so the MODEL
understands its own reply was cut off. They are not prose the user wrote
or the agent said. Persisting them into ``content`` painted the raw
machinery as an assistant bubble on every reload (and merged it into the
preceding tool-call bubble), which is what made a steered transcript
unreadable. Carry the scaffolded form in the ``api_content`` sidecar --
the exact bytes replayed to the provider -- and keep ``content`` clean.
When nothing was on screen there is no clean form at all, so the row is
marked ``display_kind="hidden"``: still replayed to the model, dropped by
every transcript surface (desktop, TUI, CLI resume), exactly like the
compaction-reference rows.
"""
reasoning = str(
getattr(agent, "_current_streamed_reasoning_text", "") or ""
).strip()
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
if reasoning:
checkpoint_parts.extend(
["Reasoning shown before the interruption:", reasoning]
)
if visible:
checkpoint_parts.extend(
["Visible response before the interruption:", visible]
@ -150,13 +175,25 @@ def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text
f"{checkpoint}\n\n"
f"{text}"
)
messages.append({"role": "user", "content": correction})
# Transcript shows the user's own words; the provider replays the
# scaffolded form so it still sees the interrupted context.
messages.append(
{"role": "user", "content": text, "api_content": correction}
)
else:
messages.append({"role": "assistant", "content": checkpoint})
entry: Dict[str, Any] = {
"role": "assistant",
"content": visible or checkpoint,
"api_content": checkpoint,
}
if not visible:
# Nothing reached the screen — this row carries no assistant prose
# at all, only the cut-off notice for the model.
entry["display_kind"] = "hidden"
messages.append(entry)
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
agent._current_streamed_reasoning_text = ""
agent._stream_needs_break = True
@ -444,30 +481,13 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
# first turn — flip-flopping the wire shape mid-conversation and
# silently degrading to the legacy single-breakpoint layout.
#
# Safety: the rebuilt stable tier is used ONLY when the restored
# prompt literally starts with it (checked here AND re-checked by
# ``_apply_system_cache_markers``'s ``startswith`` gate). If any
# stable-tier input changed since the prompt was persisted (skills
# edited, identity changed), the prefix mismatches, ``_static``
# stays None, and the request falls back to the legacy layout with
# the restored prompt bytes untouched — never a rewritten prompt.
#
# Gated on ``_use_prompt_caching`` so non-Anthropic routes skip the
# rebuild entirely (the static prefix is only consumed by
# ``apply_anthropic_cache_control``).
if getattr(agent, "_use_prompt_caching", False):
try:
from agent.system_prompt import build_system_prompt_parts as _build_parts
# ``reconstruct_static_prefix`` gates on ``_use_prompt_caching`` (so
# non-Anthropic routes skip the rebuild), applies the startswith
# safety gate (stored prompt bytes are never rewritten), and
# fails open to the legacy cache layout.
from agent.system_prompt import reconstruct_static_prefix
_static = _build_parts(agent, system_message=system_message)["stable"]
if _static and stored_prompt.startswith(_static):
agent._cached_system_prompt_static = _static
except Exception:
# Fail-open: restore continues with the legacy cache layout.
logger.debug(
"static system-prefix reconstruction failed on restore",
exc_info=True,
)
reconstruct_static_prefix(agent, system_message=system_message)
return
if stored_prompt:
stored_state = "stale_runtime"
@ -774,6 +794,41 @@ def _compression_deferred_result(
}
def _rewrite_system_content_blocks(system_message: dict, effective: str) -> bool:
"""Rewrite a cache-decorated system message in place, keeping its blocks.
``apply_anthropic_cache_control`` runs once per call block, *before* the
retry loop, and splits the system prompt into ``[static prefix, volatile
tail]`` text blocks carrying the cache_control breakpoints. Assigning a bare
string over that list drops both breakpoints, so the failover retry ships
the whole system prompt uncached and re-bills it in full.
``rewrite_prompt_model_identity`` only touches the LAST ``Model:`` /
``Provider:`` lines, and those live in the volatile tail so the static
prefix stays byte-identical and its cache entry keeps matching. Returns
False when the shape is not one we can safely patch, so the caller falls
back to the plain-string assignment.
"""
content = system_message.get("content")
if not isinstance(content, list) or not content:
return False
if not all(
isinstance(part, dict) and part.get("type") == "text" for part in content
):
return False
if len(content) == 1:
content[0]["text"] = effective
return True
if len(content) == 2:
head = content[0].get("text") or ""
if head and effective.startswith(head):
tail = effective[len(head):]
if tail:
content[1]["text"] = tail
return True
return False
def _sync_failover_system_message(agent, api_messages, active_system_prompt):
"""Refresh the in-flight system message after a provider failover.
@ -796,10 +851,109 @@ def _sync_failover_system_message(agent, api_messages, active_system_prompt):
effective = sp
if agent.ephemeral_system_prompt:
effective = (effective + "\n\n" + agent.ephemeral_system_prompt).strip()
api_messages[0]["content"] = effective
if not _rewrite_system_content_blocks(api_messages[0], effective):
api_messages[0]["content"] = effective
return sp
def _ensure_cached_system_prompt_static(agent, system_message=None) -> None:
"""Rebuild ``_cached_system_prompt_static`` when caching becomes active.
Sessions restored under a cache-off primary skip the static-prefix rebuild
(gated on ``_use_prompt_caching`` at restore time). A later failover to a
cache-on provider would otherwise redecorate with ``static_system_prefix=
None`` and silently fall back to the legacy system-plus-3 layout (#72626).
Thin wrapper over :func:`agent.system_prompt.reconstruct_static_prefix`,
which memoizes failed rebuilds so this stays cheap on the retry-loop hot
path (it runs at the top of every attempt).
"""
from agent.system_prompt import reconstruct_static_prefix
reconstruct_static_prefix(
agent, system_message=system_message, log_label="failover redecoration"
)
def _peel_moa_guidance(
messages: List[Dict[str, Any]],
guidance: Any,
) -> List[Dict[str, Any]]:
"""Remove MoA reference guidance previously attached by ``_attach_reference_guidance``.
Thin wrapper over :func:`agent.moa_loop.peel_reference_guidance` (kept
adjacent to the attach so the forward/inverse shapes evolve together).
Lazy import mirrors the module's other moa_loop touchpoints.
"""
from agent.moa_loop import peel_reference_guidance
return peel_reference_guidance(messages, guidance)
def _redecorate_prompt_cache_for_provider(
agent,
api_messages: List[Dict[str, Any]],
*,
system_message=None,
moa_prepared: Optional[Dict[str, Any]] = None,
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""Strip and re-apply cache_control for the *current* provider policy.
Decoration runs once per call block before the retry loop for the primary
provider. ``try_activate_fallback`` refreshes ``_use_prompt_caching`` /
``_use_native_cache_layout`` but the nine failover ``continue`` paths reused
the old ``api_messages`` (#72626). Mirror ``_reapply_reasoning_echo_for_provider``
by reshaping at the top of each retry attempt.
The source list is the mutated in-flight request (image shrink / ASCII /
reasoning_details recoveries already applied) never a pristine
pre-decoration snapshot. MoA guidance is peeled, the base is redecorated,
then ``rebase_prepared_request`` re-attaches guidance outside the cached
span.
"""
messages: List[Dict[str, Any]] = [
dict(m) if isinstance(m, dict) else m for m in (api_messages or [])
]
prepared = moa_prepared
guidance = prepared.get("guidance") if isinstance(prepared, dict) else None
if guidance:
messages = _peel_moa_guidance(messages, guidance)
strip_anthropic_cache_control(messages)
# Direct attribute access matches the call-block decoration site — the
# flags are unconditionally initialized on AIAgent, and a getattr
# default here would mask a real init bug as silent cache-off.
if agent._use_prompt_caching:
_ensure_cached_system_prompt_static(agent, system_message=system_message)
static = getattr(agent, "_cached_system_prompt_static", None)
messages = apply_anthropic_cache_control(
messages,
cache_ttl=agent._cache_ttl,
native_anthropic=agent._use_native_cache_layout,
static_system_prefix=static if isinstance(static, str) else None,
)
if (
prepared is not None
and getattr(agent, "provider", None) == "moa"
):
# No `and guidance` here: guidance=None is a real prepared shape
# (all-references-failed / silent degraded policy builds the
# prepared request without attaching guidance), and the MoA facade
# sends prepared["messages"] — not api_kwargs["messages"] — so the
# rebase must refresh the prepared object even when there is no
# guidance to re-attach. rebase_prepared_request handles falsy
# guidance by copying the messages and skipping the attach.
completions = getattr(getattr(agent.client, "chat", None), "completions", None)
rebase = getattr(completions, "rebase_prepared_request", None)
if callable(rebase):
prepared = rebase(prepared, messages)
messages = prepared["messages"]
return messages, prepared
def _apply_context_engine_selection(
agent: Any,
api_messages: List[Dict[str, Any]],
@ -939,6 +1093,8 @@ def run_conversation(
stream_callback: Optional[callable] = None,
persist_user_message: Optional[Any] = None,
persist_user_timestamp: Optional[float] = None,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
moa_config: Optional[dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
@ -957,6 +1113,13 @@ def run_conversation(
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
persist_user_display_kind: Optional presentation type for a
synthesized user turn (``auto_continue``, ``model_switch``, ).
Display-only: transcript surfaces render the row as a timeline
event instead of a user bubble, while the model still receives
the message unchanged.
persist_user_display_metadata: Optional payload for that event
(e.g. a delegation's task count).
or queuing follow-up prefetch work.
Returns:
@ -999,6 +1162,8 @@ def run_conversation(
stream_callback,
persist_user_message,
persist_user_timestamp,
persist_user_display_kind=persist_user_display_kind,
persist_user_display_metadata=persist_user_display_metadata,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,
@ -1025,6 +1190,9 @@ def run_conversation(
# Commentary deduplication spans all provider continuations and tool calls
# within one user turn, but must not suppress the same phrase next turn.
agent._delivered_interim_texts = set()
# A configured SessionDB append failure halts only the affected turn. A
# cached gateway agent must recover on the next message if storage did.
agent._incremental_persistence_failed = False
# Main conversation loop counters (pure locals consumed by the loop below).
api_call_count = 0
@ -1507,6 +1675,15 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# NOTE (empty-content class fix): no send-time pad loop here. The
# single owner for "never send a turn strict wire validation rejects
# as empty" is ``repair_empty_non_final_messages``, which runs inside
# ``_sanitize_api_messages`` above — the unconditional pre-send
# chokepoint shared with the summary path. Its placeholder is
# non-whitespace, so it survives the whitespace-normalization pass
# regardless of ordering (a single-space pad here previously had to
# be sequenced after normalization to survive, forking the concept).
# Apply Anthropic prompt caching for Claude models on native
# Anthropic, OpenRouter, and third-party Anthropic-compatible
# gateways. Auto-detected: if ``_use_prompt_caching`` is set, inject
@ -1870,6 +2047,18 @@ def run_conversation(
# unless the active provider needs it) so the fallback request
# isn't sent with stale, primary-shaped reasoning fields.
agent._reapply_reasoning_echo_for_provider(api_messages)
# Same story for prompt-cache decoration (#72626): try_activate_
# fallback refreshes the policy flags, but the decorated list
# still carries the primary's breakpoints (or none). Strip and
# re-render for the current provider before building kwargs.
api_messages, _moa_prepared_request = (
_redecorate_prompt_cache_for_provider(
agent,
api_messages,
system_message=system_message,
moa_prepared=_moa_prepared_request,
)
)
api_kwargs = agent._build_api_kwargs(api_messages)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(api_kwargs)
@ -2335,6 +2524,17 @@ def run_conversation(
_backoff_touch_counter = 0
while time.time() < sleep_end:
if agent._interrupt_requested:
# A redirect uses the interrupt machinery to cancel
# only the live request. Aborting the retry here
# with clear_interrupt() would DESTROY the pending
# correction and kill the turn with "Operation
# interrupted" — the exact mid-stream steer loss
# users hit when a redirect lands during provider
# backoff. Rebuild from the correction instead,
# mirroring the InterruptedError handler.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -2356,6 +2556,8 @@ def run_conversation(
f"retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
break # rebuild this iteration from the correction
continue # Retry the API call
agent._turn_received_provider_response = True
@ -2661,10 +2863,27 @@ def run_conversation(
)
if assistant_message is not None and not _trunc_has_tool_calls:
length_continue_retries += 1
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(interim_msg)
if assistant_message.content:
truncated_response_parts.append(assistant_message.content)
# An EMPTY partial-stream stub (stream dropped
# mid tool-call before any text was delivered)
# must not be appended as an interim assistant
# message: it would serialize as
# {"role": "assistant", "content": ""}, and
# strict providers (Moonshot/Kimi via OpenRouter)
# reject empty assistant content with HTTP 400
# ("message ... with role 'assistant' must not be
# empty") on the very next replay — permanently
# poisoning the session history. There is no
# partial text to continue from anyway, so only
# the continuation user-message is appended.
_is_empty_partial_stub = (
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
and not getattr(assistant_message, "content", None)
)
if not _is_empty_partial_stub:
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(interim_msg)
if assistant_message.content:
truncated_response_parts.append(assistant_message.content)
if length_continue_retries < 4:
_is_partial_stream_stub = (
@ -3559,7 +3778,7 @@ def run_conversation(
agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...")
continue
if (
agent.api_mode == "chat_completions"
agent.api_mode in ("chat_completions", "anthropic_messages")
and agent.provider == "nous"
and status_code == 401
and not _retry.nous_auth_retry_attempted
@ -3831,6 +4050,12 @@ def run_conversation(
# Check for interrupt before deciding to retry
if agent._interrupt_requested:
# Preserve a pending redirect (mid-stream correction): the
# user is steering, not stopping. Rebuild the turn from the
# correction instead of aborting with a dead-end interrupt.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -5067,6 +5292,12 @@ def run_conversation(
_backoff_touch_counter = 0
while time.time() < sleep_end:
if agent._interrupt_requested:
# Same preserve-redirect rule as the retry-wait above:
# a steering correction must survive backoff, not die
# as "Operation interrupted".
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
close_interrupted_tool_sequence(messages, _interrupt_text)
@ -5088,6 +5319,11 @@ def run_conversation(
f"error retry backoff ({retry_count}/{max_retries}), "
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
# Leave the retry loop — the check right below rebuilds this
# iteration from the correction instead of re-firing the
# stale request.
break
if _retry.restart_with_redirected_messages:
# The cancelled request produced no valid assistant item. Reuse the
@ -5433,6 +5669,14 @@ def run_conversation(
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
# Uniquify duplicate tool-call ids BEFORE any downstream
# consumer (validation error paths, dispatch, history build,
# Responses item-id derivation). Models that reuse one id for
# different calls in a batch otherwise lose the later call's
# result: the pre-API sanitizer keeps only the first
# call/result pair per id. See _uniquify_tool_call_ids.
agent._uniquify_tool_call_ids(assistant_message.tool_calls)
# Validate tool call names - detect model hallucinations
# Repair mismatched tool names before validating
for tc in assistant_message.tool_calls:
@ -5741,8 +5985,6 @@ def run_conversation(
and previous_interim_visible == current_interim_visible
)
messages.append(assistant_msg)
if not duplicate_previous_interim:
agent._emit_interim_assistant_message(assistant_msg)
# Mixed batch: error-result the invalid calls and strip them
# from the execution set. The assistant message above keeps
@ -5764,13 +6006,17 @@ def run_conversation(
if tc.function.name in agent.valid_tool_names
]
_tool_turn_persisted = None
try:
# Persist the assistant tool-call turn before any tool
# side effects run. If a destructive tool restarts or
# terminates Hermes mid-turn, resume logic still sees the
# exact tool-call block that already executed.
agent._flush_messages_to_session_db(messages, conversation_history)
_tool_turn_persisted = agent._flush_messages_to_session_db(
messages, conversation_history
)
except Exception as exc:
_tool_turn_persisted = False
logger.warning(
"Incremental tool-call persistence failed before execution "
"(session=%s): %s",
@ -5778,6 +6024,22 @@ def run_conversation(
exc,
)
if _tool_turn_persisted is False:
# The canonical append failed. Do not project the row or
# run side-effecting tools from state that exists only in
# this process. Breaking also avoids retrying the same
# unpersisted turn until the iteration budget is exhausted.
_turn_exit_reason = "session_persistence_failed"
final_response = ""
failed = True
break
# A UI must never observe an assistant/tool-call row that is
# still only an ephemeral in-memory projection. Emit interim
# commentary only after the canonical SessionDB append above.
if not duplicate_previous_interim:
agent._emit_interim_assistant_message(assistant_msg)
# Close any open streaming display (response box, reasoning
# box) before tool execution begins. Intermediate turns may
# have streamed early content that opened the response box;
@ -5792,6 +6054,15 @@ def run_conversation(
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
if getattr(agent, "_incremental_persistence_failed", False):
# A tool result could not be made canonical. Do not send
# the in-memory result back to the model or project any
# later events from this turn.
_turn_exit_reason = "session_persistence_failed"
final_response = ""
failed = True
break
if agent._tool_guardrail_halt_decision is not None:
decision = agent._tool_guardrail_halt_decision
_turn_exit_reason = "guardrail_halt"
@ -6264,7 +6535,28 @@ def run_conversation(
". No fallback providers configured.")
)
final_response = "(empty)"
# Deliver a labeled reasoning excerpt instead of a bare
# "(empty)" when the model DID think but never produced
# visible text. This is delivery-only: the persisted
# assistant message above keeps the "(empty)" sentinel
# (its replay semantics prevent empty-response loops),
# and raw chain-of-thought is never promoted to a normal
# answer earlier in the ladder — prefill continuation,
# empty-content retries, and provider fallback all run
# first. Only at this terminal, where the alternative is
# returning nothing, is showing the model's own reasoning
# (clearly labeled as such) strictly more useful.
# Idea credit: PR #48795 (@ligl0325).
if reasoning_text:
final_response = (
"⚠️ The model produced only internal reasoning and "
"no final answer, despite retries"
+ (" and fallback" if agent._fallback_chain else "")
+ ". Its last reasoning, which may contain the "
"answer:\n\n" + reasoning_preview
)
else:
final_response = "(empty)"
break
# Reset retry counter/signature on successful content

View file

@ -159,6 +159,14 @@ _RATE_LIMIT_PATTERNS = [
"throttlingexception",
"too many concurrent requests",
"servicequotaexceededexception",
# Generic throttle prefix — Bedrock (and some proxies) surface throttling
# as "Throttling error: Too many tokens, please wait before trying
# again." Without this entry the message falls through to the
# context-overflow list (which contains "too many tokens") and the retry
# loop compresses a healthy session instead of backing off. Matched
# BEFORE _CONTEXT_OVERFLOW_PATTERNS in the message-only path, so the
# throttle wins. (port of anomalyco/opencode#37848's exclusion guard)
"throttling",
]
# Patterns that indicate provider-side overload, NOT a per-credential rate
@ -212,6 +220,12 @@ _PAYLOAD_TOO_LARGE_PATTERNS = [
"request entity too large",
"payload too large",
"error code: 413",
# Anthropic's structured 413 error type. Normally arrives with an HTTP
# 413 status (handled by the status path), but aggregators/proxies can
# re-wrap it into a plain message with no status attribute — route it to
# the same compression recovery. (port of anomalyco/opencode#37848)
"request_too_large",
"request exceeds the maximum size",
]
# Image-size patterns. Matched against 400 bodies (not 413) because most
@ -298,6 +312,10 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"max input token",
"input token",
"exceeds the maximum number of input tokens",
# Together/Fireworks-style: "Input length 131393 exceeds the maximum
# allowed input length of 131040 tokens." No other pattern in this list
# matches that wording. (port of anomalyco/opencode#37848)
"maximum allowed input length",
]
# Model not found patterns
@ -321,6 +339,30 @@ _MODEL_NOT_FOUND_PATTERNS = [
"no endpoints found that support tool use",
]
# Malformed-message-array 400s. Deterministic request-shape rejections that
# describe the *transcript* being invalid, not a parameter. The canonical
# case: a stream dies mid-response and Hermes persists a content-less
# assistant stub; on the next turn the Anthropic message schema (and the
# litellm/Bedrock proxies in front of it) reject the whole request with
# "all messages must have non-empty content except for the optional final
# assistant message" / errorCode INVALID_REQUEST_BODY
# These are NOT context overflow — the input may be tiny — but a large
# session used to mis-route them into the compression loop via the generic
# "400 + large session" heuristic below, ending in "Cannot compress further"
# every retry (the input is unchanged, so compression cannot help). Match
# the message-shape signals explicitly and fail fast as a format_error so the
# loop stops looping. The empty-stub creation is the root cause (fixed in
# chat_completion_helpers); this pattern stops the misclassification symptom
# for transcripts that already contain a poisoned stub.
_INVALID_MESSAGE_BODY_PATTERNS = [
"must have non-empty content",
"messages must have non-empty",
"invalid_request_body",
"text content blocks must be non-empty",
"content field is required",
"messages: at least one message is required",
]
# Request-validation patterns — the request is malformed and will fail
# identically on every retry. Some OpenAI-compatible gateways (notably
# codex.nekos.me) return these as 5xx instead of the standard 4xx, which
@ -1271,6 +1313,33 @@ def _classify_400(
should_fallback=True,
)
# Malformed message array (empty-content assistant stub, etc.). Must be
# checked BEFORE context_overflow: the input can be tiny, so the generic
# "400 + large session" heuristic would otherwise mis-route it into the
# compression loop and thrash until "Cannot compress further" on every
# retry (the request is unchanged, so compression cannot fix it). This is
# a deterministic request-shape rejection — fail fast as a non-retryable
# format_error and fall back. Checked against the message text AND the
# structured error code, since proxies (litellm/Bedrock) surface the
# signal in errorCode=INVALID_REQUEST_BODY.
if (
any(p in error_msg for p in _INVALID_MESSAGE_BODY_PATTERNS)
or error_code_lower == "invalid_request_body"
):
logger.warning(
"Malformed message array 400 (invalid request body) classified as "
"format_error, NOT context overflow — failing fast + falling back "
"instead of entering the compression loop. This usually means an "
"empty-content assistant stub is in the transcript; num_messages=%s "
"approx_tokens=%s. error=%.200s",
num_messages, approx_tokens, error_msg,
)
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Empty-provider-response advisories must not enter compression. They
# often mention "max_tokens" as a possible cause and used to match the
# bare overflow pattern, then thrash compress until "Cannot compress
@ -1331,6 +1400,18 @@ def _classify_400(
# Responses API (and some providers) use flat body: {"message": "..."}
if not err_body_msg:
err_body_msg = str(body.get("message") or "").strip().lower()
# litellm / Bedrock proxies use a custom shape: {"errorMessage": "...",
# "errorCode": "...", "errorArgs": {"reason": "..."}}. Without these
# keys err_body_msg stays "" and a long, descriptive rejection is
# wrongly treated as a "generic" (bare) error below, which — on a
# large session — mis-routes into the compression loop. Recognize
# them so the is_generic heuristic sees the real message length.
if not err_body_msg:
err_body_msg = str(body.get("errorMessage") or "").strip().lower()
if not err_body_msg:
_args = body.get("errorArgs")
if isinstance(_args, dict):
err_body_msg = str(_args.get("reason") or "").strip().lower()
is_generic = len(err_body_msg) < 30 or err_body_msg in {"error", ""}
# Absolute token/message-count thresholds are only a proxy for smaller
# context windows. Large-context sessions can have many messages while
@ -1629,7 +1710,7 @@ def _extract_error_code(body: dict) -> str:
return nested_code
# Top-level code
code = body.get("code") or body.get("error_code") or ""
code = body.get("code") or body.get("error_code") or body.get("errorCode") or ""
if isinstance(code, (str, int)):
text = str(code).strip()
if text and text != "400":
@ -1649,6 +1730,16 @@ def _extract_message(error: Exception, body: dict) -> str:
msg = body.get("message", "")
if isinstance(msg, str) and msg.strip():
return msg.strip()[:500]
# litellm / Bedrock proxy shape: {"errorMessage": "...",
# "errorArgs": {"reason": "..."}}.
msg = body.get("errorMessage", "")
if isinstance(msg, str) and msg.strip():
return msg.strip()[:500]
args = body.get("errorArgs")
if isinstance(args, dict):
reason = args.get("reason", "")
if isinstance(reason, str) and reason.strip():
return reason.strip()[:500]
# Fallback to str(error)
return str(error)[:500]

View file

@ -163,6 +163,42 @@ _FREE_TIER_GUIDANCE = (
)
def is_standard_key_auth_error(
status: int, error_message: str, reason: str = ""
) -> bool:
"""Return True when a Gemini 401 indicates Google rejected the key TYPE.
Google began rejecting unrestricted legacy "Standard" Google Cloud API
keys on the Gemini API on June 19, 2026, and ALL Standard keys stop
working in September 2026. The rejection surfaces as a misleading 401
telling the user to supply an OAuth 2 access token ("Request had invalid
authentication credentials. Expected OAuth 2 access token, login cookie
or other valid authentication credential."), optionally carrying
``google.rpc.ErrorInfo`` reason ``ACCESS_TOKEN_TYPE_UNSUPPORTED``.
Scoped narrowly so a plain bad key (reason ``API_KEY_INVALID``,
"API key not valid") keeps its existing message.
"""
if status != 401:
return False
if reason == "ACCESS_TOKEN_TYPE_UNSUPPORTED":
return True
return "expected oauth 2 access token" in (error_message or "").lower()
_STANDARD_KEY_GUIDANCE = (
"\n\nGoogle Gemini rejected this API key's type — you do NOT need OAuth. "
"Google began rejecting legacy 'Standard' Google Cloud keys for the "
"Gemini API on June 19, 2026, and all Standard keys stop working in "
"September 2026. Open https://aistudio.google.com/api-keys, check the "
"key's type and status, and create a replacement Gemini API key (or, as "
"a temporary bridge, restrict the Standard key to "
"generativelanguage.googleapis.com). Then update GEMINI_API_KEY / "
"GOOGLE_API_KEY in ~/.hermes/.env and restart your session. "
"Details: https://ai.google.dev/gemini-api/docs/api-key"
)
class GeminiAPIError(Exception):
"""Error shape compatible with Hermes retry/error classification."""
@ -824,6 +860,12 @@ def gemini_http_error(
if status == 429 and is_free_tier_quota_error(err_message or body_text):
message = message + _FREE_TIER_GUIDANCE
# Legacy "Standard" Google Cloud key rejection (June 19, 2026 onward) ->
# Google's raw 401 misleadingly tells the user to use OAuth. Append the
# actual fix (mint a new Gemini API key in AI Studio).
if is_standard_key_auth_error(status, err_message or body_text, reason):
message = message + _STANDARD_KEY_GUIDANCE
return GeminiAPIError(
message,
code=code,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any, Dict
# Gemini's ``FunctionDeclaration.parameters`` field accepts the ``Schema``
@ -76,15 +77,31 @@ def sanitize_gemini_schema(schema: Any) -> Dict[str, Any]:
# Gemini's Schema validator requires every ``enum`` entry to be a string,
# even when the parent ``type`` is ``integer`` / ``number`` / ``boolean``.
# OpenAI / OpenRouter / Anthropic accept typed enums (e.g. Discord's
# ``auto_archive_duration: {type: integer, enum: [60, 1440, 4320, 10080]}``),
# so we only drop the ``enum`` when it would collide with Gemini's rule.
# Keeping ``type: integer`` plus the human-readable description gives the
# model enough guidance; the tool handler still validates the value.
# Preserve those constraints by stringifying scalar values while keeping
# the declared type intact; Gemini uses the strings as schema metadata and
# still emits typed tool arguments at runtime.
enum_val = cleaned.get("enum")
type_val = cleaned.get("type")
if isinstance(enum_val, list) and type_val in {"integer", "number", "boolean"}:
if any(not isinstance(item, str) for item in enum_val):
stringified = []
for item in enum_val:
if isinstance(item, str):
value = item
elif isinstance(item, bool):
value = "true" if item else "false"
elif (
isinstance(item, (int, float))
and not isinstance(item, bool)
and math.isfinite(item)
):
value = str(item)
else:
continue
if value not in stringified:
stringified.append(value)
if stringified:
cleaned["enum"] = stringified
else:
cleaned.pop("enum", None)
# Gemini validates ``required`` strictly against the same node's

View file

@ -2,7 +2,7 @@
Extracted from ``run_agent.py``. Each ``AIAgent`` instance (parent or
subagent) holds an :class:`IterationBudget`; the parent's cap comes from
``max_iterations`` (default 90), each subagent's cap comes from
``max_iterations`` (default 500), each subagent's cap comes from
``delegation.max_iterations`` (default 50).
``run_agent`` re-exports ``IterationBudget`` so existing
@ -18,7 +18,7 @@ class IterationBudget:
"""Thread-safe iteration counter for an agent.
Each agent (parent or subagent) gets its own ``IterationBudget``.
The parent's budget is capped at ``max_iterations`` (default 90).
The parent's budget is capped at ``max_iterations`` (default 500).
Each subagent gets an independent budget capped at
``delegation.max_iterations`` (default 50) this means total
iterations across parent + subagents can exceed the parent's cap.

View file

@ -1337,6 +1337,63 @@ def _attach_reference_guidance(agg_messages: list[dict[str, Any]], guidance: str
agg_messages.append({"role": "user", "content": guidance})
def peel_reference_guidance(
messages: list[dict[str, Any]],
guidance: Any,
) -> list[dict[str, Any]]:
"""Remove reference guidance previously attached by ``_attach_reference_guidance``.
Exact inverse of the three attach shapes above (string merge, trailing
text part, appended user message) kept adjacent so the two evolve
together; a drifting separator or shape would make the peel silently
no-op and let a cache breakpoint land on the turn-varying guidance
block (the bug class #72626 fixes).
Used by the failover redecoration chokepoint: redecoration must run on
the base transcript so the last cache breakpoint does not land on the
guidance; callers then rebase via ``rebase_prepared_request``.
Returns a new list (input list and its messages are not mutated).
"""
if not guidance or not messages:
return messages
guidance_text = str(guidance)
last = messages[-1]
if not isinstance(last, dict) or last.get("role") != "user":
return messages
content = last.get("content")
if content == guidance_text:
# Attach shape (c): guidance was appended as its own user message.
return list(messages[:-1])
suffix = "\n\n" + guidance_text
if isinstance(content, str) and content.endswith(suffix):
# Attach shape (a): merged into a trailing string user turn.
peeled = dict(last)
peeled["content"] = content[: -len(suffix)]
return [*messages[:-1], peeled]
if isinstance(content, list) and content:
last_part = content[-1]
if isinstance(last_part, dict) and last_part.get("type", "text") == "text":
text = last_part.get("text") or ""
if text == suffix or text == guidance_text:
# Attach shape (b): guidance rode as its own trailing part.
peeled = dict(last)
peeled["content"] = list(content[:-1])
if not peeled["content"]:
# The guidance part was the only content — mirror the
# string shape (c) and drop the whole message rather
# than leaving an empty-content user turn behind.
return list(messages[:-1])
return [*messages[:-1], peeled]
if text.endswith(suffix):
new_part = dict(last_part)
new_part["text"] = text[: -len(suffix)]
peeled = dict(last)
peeled["content"] = [*content[:-1], new_part]
return [*messages[:-1], peeled]
return messages
class MoAChatCompletions:
"""OpenAI-chat-compatible facade where the aggregator is the acting model."""

View file

@ -119,6 +119,59 @@ def _apply_system_cache_markers(
return 1
def strip_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Remove ``cache_control`` markers and undo decoration-produced list shapes.
Used before re-applying decoration after a mid-turn provider failover so
the mutated, undecorated shape (image shrink / ASCII cleanup / etc.) is
preserved while markers match the *new* provider's cache policy (#72626).
Flattening back to a plain string is restricted to the exact shapes
:func:`apply_anthropic_cache_control` produces from string content
a single ``{"type": "text"}`` part, or the two-part ``[static, volatile]``
system split so the ``""``-join is provably byte-exact. Organic
multi-part text (merged user turns, imported transcripts) and parts
carrying extra keys (``citations`` etc.) keep their structure; only
per-part markers are removed. Marker removal is copy-on-write on the
part dicts: content parts may alias the persistent conversation history
(the per-call copy is shallow), and stripping must never rewrite the
stored transcript.
Mutates the top-level message dicts of ``api_messages`` in place and
returns the same list.
"""
for msg in api_messages:
if not isinstance(msg, dict):
continue
msg.pop("cache_control", None)
content = msg.get("content")
if not isinstance(content, list):
continue
if any(isinstance(part, dict) and "cache_control" in part for part in content):
content = [
{k: v for k, v in part.items() if k != "cache_control"}
if isinstance(part, dict) and "cache_control" in part
else part
for part in content
]
msg["content"] = content
decoration_shape = content and all(
isinstance(part, dict)
and part.get("type", "text") == "text"
and isinstance(part.get("text"), str)
and set(part.keys()) <= {"type", "text"}
for part in content
) and (
len(content) == 1
or (msg.get("role") == "system" and len(content) == 2)
)
if decoration_shape:
msg["content"] = "".join(part["text"] for part in content)
return api_messages
def apply_anthropic_cache_control(
api_messages: List[Dict[str, Any]],
cache_ttl: str = "5m",

View file

@ -106,6 +106,14 @@ _REASONING_STALE_TIMEOUT_FLOORS: tuple[tuple[str, int], ...] = (
("claude-sonnet-5", 180),
("claude-sonnet-4.5", 180),
("claude-sonnet-4.6", 180),
# Anthropic Mythos-class named reasoning models (claude-fable-5, …).
# 1M context + 128K output — heavier thinking phase than the
# numbered Claude line, so the floor is in the deep-reasoning tier
# alongside o1 / deepseek-r1 / nemotron-3-ultra. Without this
# entry the stale-stream detector kills fable-5's thinking phase
# at the default 180s (300s with context scaling), tripping the
# cross-turn circuit breaker after 5 consecutive stale kills.
("claude-fable", 600),
# xAI Grok reasoning variants. Explicit reasoning-only keys
# plus one for the ``non-reasoning`` variant so users picking
# the fast variant don't get the 300s floor. Bare ``grok-3``,
@ -207,6 +215,8 @@ def get_reasoning_stale_timeout_floor(model: object) -> Optional[float]:
300.0
>>> get_reasoning_stale_timeout_floor("anthropic/claude-opus-4-6")
240.0
>>> get_reasoning_stale_timeout_floor("anthropic/claude-fable-5")
600.0
>>> get_reasoning_stale_timeout_floor("gpt-4o") is None
True
>>> get_reasoning_stale_timeout_floor("olmo-1") is None

View file

@ -175,6 +175,85 @@ _YAML_ASSIGN_RE = re.compile(
re.IGNORECASE | re.MULTILINE,
)
# Word-boundary validation for the mixed/lowercase key patterns above
# (_CFG_DOTTED_RE, _CFG_ANCHORED_RE, _YAML_ASSIGN_RE).
#
# Those key classes allow arbitrary alphanumeric affixes around the secret
# keyword so real key names like ``client_secret``, ``clientSecret``, and
# ``s3.secret-key`` match. The side effect: ordinary prose/document words that
# merely CONTAIN a keyword also matched — ``Secretary: J.Smith`` (secret),
# ``tokenizer: cl100k_base`` (token), ``author=Smith`` (auth) — mangling
# legitimate content on the surfaces that run these passes (browser snapshots,
# log lines, kanban summaries, CLI-echoed command output). Ported from
# nearai/ironclaw#6129, where the same substring false positive ("Secretary of
# the Treasury" matching the ``secret`` marker) scrubbed legitimate tool
# results from the replayed transcript and sent the model into a re-fetch
# loop.
#
# A keyword occurrence only counts when it sits at a word boundary within the
# key: at the key's edge, next to a non-letter (``_ - . 3``), or at a
# camelCase transition (``clientSecret``, ``secretKey``, ``APIToken``). A
# trailing plural ``s`` is treated as part of the keyword (``secrets:``,
# ``tokens:``). Common concatenated compounds keep matching via explicit
# alternatives (``authtoken`` ngrok, ``authkey`` tailscale, ``secretkey``
# minio, ``apikey``). Embedded occurrences inside a larger word
# (``secretary``, ``tokenizer``, ``authored``, ``credentialing``) no longer
# match. ALL-CAPS keys keep the legacy embedded matching (``MYTOKEN=…``) — an
# all-caps key is almost never prose, the same rationale as _ENV_ASSIGN_RE.
_KEY_KEYWORD_RE = re.compile(
r"(?:api|auth|access|refresh|session|secret)[ _.\-]?(?:key|token)"
r"|token|secret|passwd|password|credential|auth",
re.IGNORECASE,
)
def _is_word_start(s: str, i: int) -> bool:
"""True if position ``i`` in ``s`` begins a word (not mid-word)."""
if i == 0:
return True
prev, cur = s[i - 1], s[i]
if not prev.isalpha():
return True
if cur.isupper() and prev.islower():
return True # camelCase: clientSecret
# Acronym run ending: APIToken — the 'T' begins a new word when it is
# followed by lowercase while the preceding run is uppercase.
if cur.isupper() and prev.isupper() and i + 1 < len(s) and s[i + 1].islower():
return True
return False
def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool:
"""True if position ``j`` (exclusive end) in ``s`` ends a word."""
if j >= len(s):
return True
cur = s[j]
if not cur.isalpha():
return True
if cur.isupper() and s[j - 1].islower():
return True # camelCase continuation: secretKey
if allow_plural and cur in "sS":
return _is_word_end(s, j + 1, allow_plural=False)
return False
def _key_has_secret_keyword(key: str) -> bool:
"""True if ``key`` contains a secret keyword at a word boundary.
Post-match validator for _CFG_DOTTED_RE / _CFG_ANCHORED_RE /
_YAML_ASSIGN_RE hits rejects prose words that merely embed a keyword
(``secretary``, ``tokenizer``, ``authored``). Safe to call with the
_ENV_ASSIGN_RE key too: all-caps keys short-circuit to the legacy
embedded-match behavior.
"""
letters = [c for c in key if c.isalpha()]
if letters and all(c.isupper() for c in letters):
return True # legacy all-caps behavior (MYTOKEN=…)
for m in _KEY_KEYWORD_RE.finditer(key):
if _is_word_start(key, m.start()) and _is_word_end(key, m.end()):
return True
return False
# JSON field patterns: "apiKey": "value", "token": "value", etc.
_JSON_KEY_NAMES = r"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)"
_JSON_FIELD_RE = re.compile(
@ -614,6 +693,13 @@ def redact_sensitive_text(
# prose/log contexts (issue #2852): ``KEY=os.getenv('X')``.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
# Keyword must sit at a word boundary within the key —
# ``author=Smith`` / ``press.secretary=…`` are prose, not
# credentials (ported from nearai/ironclaw#6129). All-caps
# keys (the _ENV_ASSIGN_RE shape) short-circuit to legacy
# embedded matching inside the helper.
if not _key_has_secret_keyword(name):
return m.group(0)
return f"{name}={quote}{_mask_token(value)}{quote}"
text = _ENV_ASSIGN_RE.sub(_redact_env, text)
# Lowercase/dotted config keys (issue #16413). Skip URLs entirely —
@ -647,6 +733,11 @@ def redact_sensitive_text(
# not a leaked secret value.
if _ENV_LOOKUP_VALUE_RE.match(value):
return m.group(0)
# Keyword must sit at a word boundary within the key —
# ``Secretary: J.Smith`` / ``tokenizer: cl100k_base`` are
# document text, not credentials (nearai/ironclaw#6129).
if not _key_has_secret_keyword(key):
return m.group(0)
return f"{key}{sep}{_mask_token(value)}"
text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)

View file

@ -100,6 +100,7 @@ emitted by each built-in hook site.
child_role role string of the child agent
child_summary summary of the child's work
child_status exit status string (e.g. "success", "error")
tool_call_history redacted tool name/input summary/byte counts/status list
duration_ms wall-clock time of the child run in milliseconds
"""

View file

@ -97,7 +97,7 @@ def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
return None
def describe_skill_invocation(content: Any) -> Optional[str]:
def describe_skill_invocation(content: Any, separator: str = "") -> Optional[str]:
"""Render a slash-skill-expanded turn the way the user typed it.
The expanded message embeds the whole skill body, so any surface that
@ -109,6 +109,10 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
Returns ``"/work — fix the title leak"``, or ``"/work"`` for a bare
invocation, or ``None`` when *content* is not skill scaffolding (the
caller should then summarize it as an ordinary message).
*separator* joins the command and the instruction. Previews use the
default em dash; pass ``" "`` for the literal invocation the user typed,
which is what chat transcripts render.
"""
if not isinstance(content, str) or not content.startswith(_SKILL_INVOCATION_PREFIX):
return None
@ -127,7 +131,7 @@ def describe_skill_invocation(content: Any) -> Optional[str]:
instruction = instruction.split(SKILL_EXCERPT_JOINT)[0]
instruction = " ".join(instruction.split())
if instruction:
return f"{label}{instruction}" if name else instruction
return f"{label}{separator}{instruction}" if name else instruction
return label if name else None

View file

@ -50,7 +50,7 @@ EXCLUDED_SKILL_DIRS = frozenset(
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
def is_excluded_skill_path(path) -> bool:
def is_excluded_skill_path(path, *, root: Optional[Path] = None) -> bool:
"""True if *path* should be skipped by active skill scanners.
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
@ -66,11 +66,11 @@ def is_excluded_skill_path(path) -> bool:
from pathlib import PurePath
parts = PurePath(str(path)).parts
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
path
path, root=root
)
def is_skill_support_path(path) -> bool:
def is_skill_support_path(path, *, root: Optional[Path] = None) -> bool:
"""True if *path* is under a support dir of an actual skill root.
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
@ -92,6 +92,8 @@ def is_skill_support_path(path) -> bool:
if part not in SKILL_SUPPORT_DIRS or idx == 0:
continue
skill_root = Path(*parts[:idx])
if root is not None and not path_obj.is_absolute():
skill_root = root / skill_root
if (skill_root / "SKILL.md").exists():
return True
return False

533
agent/subagent_lifecycle.py Normal file
View file

@ -0,0 +1,533 @@
"""Public, plugin-safe lifecycle API for delegated Hermes subagents.
This module deliberately exposes immutable contracts, not ``AIAgent`` objects.
It is the supported boundary for plugins that need to supervise fresh child
sessions; plugins must obtain it from ``PluginContext.subagent_lifecycle``.
"""
from __future__ import annotations
import contextvars
import dataclasses
import enum
import hashlib
import hmac
import json
import math
import secrets
import threading
import time
from contextlib import contextmanager
from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
from typing import Any, Callable, Mapping, Optional
PUBLIC_CONTRACT_VERSION = 1
_MAX_GOAL_CHARS = 16_000
_MAX_CONTEXT_CHARS = 32_000
_MAX_METADATA_BYTES = 8_192
_MAX_RESULT_CHARS = 32_000
_TERMINAL_RETENTION_SECONDS = 3_600
class SubagentLifecycleError(ValueError):
"""A request cannot be safely accepted by the public lifecycle API."""
class SubagentState(str, enum.Enum):
PENDING = "PENDING"
STARTING = "STARTING"
RUNNING = "RUNNING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
INTERRUPTED = "INTERRUPTED"
CANCEL_REQUESTED = "CANCEL_REQUESTED"
CANCELLED = "CANCELLED"
UNKNOWN = "UNKNOWN"
@dataclasses.dataclass(frozen=True)
class SubagentLaunchRequest:
goal: str
context: Optional[str] = None
role: str = "leaf"
model: Optional[str] = None
allowed_toolsets: Optional[tuple[str, ...]] = None
blocked_tools: tuple[str, ...] = ()
working_directory: Optional[str] = None
parent_session_id: Optional[str] = None
correlation_id: Optional[str] = None
metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
timeout_seconds: Optional[float] = None
@dataclasses.dataclass(frozen=True)
class SubagentHandle:
contract_version: int
subagent_id: str
parent_session_id: Optional[str]
correlation_id: Optional[str]
created_at: float
provider: Optional[str]
model: Optional[str]
role: str
depth: int
capability: str
def to_dict(self) -> dict[str, Any]:
return dataclasses.asdict(self)
@classmethod
def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle":
try:
return cls(**dict(value))
except (TypeError, ValueError) as exc:
raise SubagentLifecycleError("Malformed subagent handle.") from exc
@dataclasses.dataclass(frozen=True)
class SubagentStatus:
handle: SubagentHandle
state: SubagentState
updated_at: float
diagnostic: Optional[str] = None
@dataclasses.dataclass(frozen=True)
class SubagentTerminalState:
handle: SubagentHandle
state: SubagentState
completed: bool
timed_out: bool = False
diagnostic: Optional[str] = None
@dataclasses.dataclass(frozen=True)
class SubagentCancelResult:
accepted: bool
already_terminal: bool = False
unknown_handle: bool = False
unsupported: bool = False
state: SubagentState = SubagentState.UNKNOWN
@dataclasses.dataclass(frozen=True)
class SubagentResult:
handle: SubagentHandle
terminal_state: SubagentState
ready: bool
summary: Optional[str] = None
structured_payload: Optional[Mapping[str, Any]] = None
started_at: Optional[float] = None
completed_at: Optional[float] = None
error_classification: Optional[str] = None
error_message: Optional[str] = None
usage_metadata: Mapping[str, Any] = dataclasses.field(default_factory=dict)
tool_execution_summary: Mapping[str, Any] = dataclasses.field(default_factory=dict)
result_hash: Optional[str] = None
@dataclasses.dataclass(frozen=True)
class SubagentReconnectResult:
connected: bool
state: SubagentState
diagnostic: Optional[str] = None
@dataclasses.dataclass
class _Record:
handle: SubagentHandle
state: SubagentState
updated_at: float
agent: Any = None
future: Optional[Future] = None
started_at: Optional[float] = None
completed_at: Optional[float] = None
result: Optional[SubagentResult] = None
class _Registry:
"""Thread-safe terminal-retention registry; never returns live records."""
def __init__(self) -> None:
self.lock = threading.RLock()
self.records: dict[str, _Record] = {}
self.correlations: dict[tuple[Optional[str], str], str] = {}
_REGISTRY = _Registry()
# Daemon worker pool: a wedged/abandoned child must never block interpreter
# exit at atexit-join time (same rationale as _run_single_child's timeout
# executor and the async-delegation registry pool).
from tools.daemon_pool import DaemonThreadPoolExecutor as _DaemonExecutor
_EXECUTOR = _DaemonExecutor(max_workers=8, thread_name_prefix="hermes-lifecycle")
_SECRET = secrets.token_bytes(32)
_ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar(
"hermes_subagent_lifecycle_parent", default=None
)
@contextmanager
def bind_subagent_parent(parent_agent: Any):
"""Bind the host-owned parent for the current agent turn."""
token = _ACTIVE_PARENT_AGENT.set(parent_agent)
try:
yield
finally:
_ACTIVE_PARENT_AGENT.reset(token)
def get_active_subagent_parent() -> Any:
"""Return the parent bound to this execution context, if any."""
return _ACTIVE_PARENT_AGENT.get()
class SubagentLifecycleService:
"""Stable public service returned by :attr:`PluginContext.subagent_lifecycle`.
Running children are in-process only. Completed results remain available
until process exit; ``reconnect`` accurately reports that a serialized
handle cannot reconnect after a restart instead of launching work again.
"""
def __init__(self, parent_agent_resolver: Callable[[], Any]) -> None:
self._parent_agent_resolver = parent_agent_resolver
def launch(self, request: SubagentLaunchRequest) -> SubagentHandle:
parent = self._parent_agent_resolver()
if parent is None:
raise SubagentLifecycleError(
"No active Hermes parent session is available."
)
self._validate_request(request, parent)
parent_session_id = str(getattr(parent, "session_id", "") or "") or None
if request.parent_session_id and request.parent_session_id != parent_session_id:
raise SubagentLifecycleError(
"parent_session_id does not match the active session."
)
correlation_key = (parent_session_id, request.correlation_id or "")
with _REGISTRY.lock:
self._cleanup_locked()
if request.correlation_id and correlation_key in _REGISTRY.correlations:
raise SubagentLifecycleError(
"Duplicate correlation_id for this parent session."
)
# Delegate construction remains internal so plugin code never imports
# private delegation helpers or manipulates the active-child registry.
from tools.delegate_tool import (
_build_child_preserving_parent_tools,
DEFAULT_MAX_ITERATIONS,
)
child = _build_child_preserving_parent_tools(
task_index=0,
goal=request.goal,
context=request.context,
toolsets=list(request.allowed_toolsets)
if request.allowed_toolsets
else None,
model=request.model,
max_iterations=DEFAULT_MAX_ITERATIONS,
task_count=1,
parent_agent=parent,
role=request.role,
)
subagent_id = str(getattr(child, "_subagent_id", "") or "")
if not subagent_id:
raise SubagentLifecycleError("Hermes failed to assign a child identity.")
created = time.time()
handle = SubagentHandle(
PUBLIC_CONTRACT_VERSION,
subagent_id,
parent_session_id,
request.correlation_id,
created,
getattr(child, "provider", None),
getattr(child, "model", None),
getattr(child, "_delegate_role", request.role),
int(getattr(child, "_delegate_depth", 1) or 1),
self._capability(subagent_id, parent_session_id, created),
)
record = _Record(handle, SubagentState.PENDING, created, agent=child)
with _REGISTRY.lock:
_REGISTRY.records[subagent_id] = record
if request.correlation_id:
_REGISTRY.correlations[correlation_key] = subagent_id
record.future = _EXECUTOR.submit(self._run, record, request.goal, parent)
return handle
def status(self, handle: SubagentHandle) -> SubagentStatus:
record = self._record(handle)
if record is None:
return SubagentStatus(
handle, SubagentState.UNKNOWN, time.time(), "UNKNOWN_HANDLE"
)
with _REGISTRY.lock:
return SubagentStatus(record.handle, record.state, record.updated_at)
def wait(
self, handle: SubagentHandle, *, timeout_seconds: Optional[float] = None
) -> SubagentTerminalState:
record = self._record(handle)
if record is None:
return SubagentTerminalState(
handle, SubagentState.UNKNOWN, True, diagnostic="UNKNOWN_HANDLE"
)
future = record.future
if future is not None:
try:
future.result(timeout=timeout_seconds)
except TimeoutError:
return SubagentTerminalState(record.handle, record.state, False, True)
except Exception:
pass
with _REGISTRY.lock:
return SubagentTerminalState(
record.handle, record.state, record.result is not None
)
def cancel(self, handle: SubagentHandle, *, reason: str) -> SubagentCancelResult:
record = self._record(handle)
if record is None:
return SubagentCancelResult(False, unknown_handle=True)
with _REGISTRY.lock:
if record.result is not None:
return SubagentCancelResult(
False, already_terminal=True, state=record.state
)
agent = record.agent
record.state = SubagentState.CANCEL_REQUESTED
record.updated_at = time.time()
if agent is None or not hasattr(agent, "interrupt"):
return SubagentCancelResult(
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
)
try:
agent.interrupt(f"Lifecycle cancellation requested: {reason[:500]}")
except Exception:
return SubagentCancelResult(
False, unsupported=True, state=SubagentState.CANCEL_REQUESTED
)
return SubagentCancelResult(True, state=SubagentState.CANCEL_REQUESTED)
def result(self, handle: SubagentHandle) -> SubagentResult:
record = self._record(handle)
if record is None:
return SubagentResult(
handle,
SubagentState.UNKNOWN,
False,
error_classification="UNKNOWN_HANDLE",
)
with _REGISTRY.lock:
if record.result is not None:
return record.result
return SubagentResult(
record.handle, record.state, False, error_classification="NOT_READY"
)
def reconnect(self, handle: SubagentHandle) -> SubagentReconnectResult:
record = self._record(handle)
if record is None:
return SubagentReconnectResult(
False, SubagentState.UNKNOWN, "RECONNECT_UNAVAILABLE"
)
with _REGISTRY.lock:
return SubagentReconnectResult(True, record.state)
def _record(self, handle: SubagentHandle) -> Optional[_Record]:
if (
not isinstance(handle, SubagentHandle)
or type(handle.contract_version) is not int
or handle.contract_version != PUBLIC_CONTRACT_VERSION
):
return None
if (
not isinstance(handle.subagent_id, str)
or not handle.subagent_id
or (
handle.parent_session_id is not None
and not isinstance(handle.parent_session_id, str)
)
or (
handle.correlation_id is not None
and not isinstance(handle.correlation_id, str)
)
or isinstance(handle.created_at, bool)
or not isinstance(handle.created_at, (int, float))
or not math.isfinite(handle.created_at)
or (handle.provider is not None and not isinstance(handle.provider, str))
or (handle.model is not None and not isinstance(handle.model, str))
or not isinstance(handle.role, str)
or type(handle.depth) is not int
or not isinstance(handle.capability, str)
):
return None
if not hmac.compare_digest(
handle.capability,
self._capability(
handle.subagent_id, handle.parent_session_id, handle.created_at
),
):
return None
parent = self._parent_agent_resolver()
active_parent_id = str(getattr(parent, "session_id", "") or "") or None
if active_parent_id != handle.parent_session_id:
return None
with _REGISTRY.lock:
return _REGISTRY.records.get(handle.subagent_id)
@staticmethod
def _cleanup_locked() -> None:
"""Retain terminal snapshots for a bounded period, never live work."""
cutoff = time.time() - _TERMINAL_RETENTION_SECONDS
expired = [
subagent_id
for subagent_id, record in _REGISTRY.records.items()
if record.result is not None
and record.completed_at is not None
and record.completed_at < cutoff
]
for subagent_id in expired:
record = _REGISTRY.records.pop(subagent_id)
if record.handle.correlation_id:
_REGISTRY.correlations.pop(
(record.handle.parent_session_id, record.handle.correlation_id),
None,
)
def _run(self, record: _Record, goal: str, parent: Any) -> None:
with _REGISTRY.lock:
if record.state is not SubagentState.CANCEL_REQUESTED:
record.state = SubagentState.RUNNING
record.started_at = time.time()
record.updated_at = record.started_at
try:
from tools.delegate_tool import _run_child_lifecycle
raw = _run_child_lifecycle(0, goal, record.agent, parent)
status = (
str(raw.get("status", "error")) if isinstance(raw, dict) else "error"
)
if status == "completed":
state = SubagentState.SUCCEEDED
elif status == "interrupted":
state = (
SubagentState.CANCELLED
if record.state == SubagentState.CANCEL_REQUESTED
else SubagentState.INTERRUPTED
)
else:
state = SubagentState.FAILED
summary = raw.get("summary") if isinstance(raw, dict) else None
summary = str(summary)[:_MAX_RESULT_CHARS] if summary is not None else None
error = raw.get("error") if isinstance(raw, dict) else None
result = SubagentResult(
record.handle,
state,
True,
summary=summary,
completed_at=time.time(),
started_at=record.started_at,
error_classification=None
if state == SubagentState.SUCCEEDED
else status.upper(),
error_message=str(error)[:_MAX_RESULT_CHARS] if error else None,
usage_metadata={"api_calls": raw.get("api_calls", 0)}
if isinstance(raw, dict)
else {},
tool_execution_summary={
"duration_seconds": raw.get("duration_seconds", 0)
}
if isinstance(raw, dict)
else {},
)
except Exception as exc:
result = SubagentResult(
record.handle,
SubagentState.FAILED,
True,
started_at=record.started_at,
completed_at=time.time(),
error_classification=type(exc).__name__,
error_message=str(exc)[:_MAX_RESULT_CHARS],
)
payload = dataclasses.asdict(result)
payload.pop("result_hash", None)
result = dataclasses.replace(
result,
result_hash=hashlib.sha256(
json.dumps(payload, sort_keys=True, default=str).encode()
).hexdigest(),
)
with _REGISTRY.lock:
record.agent = None
record.result = result
record.state = result.terminal_state
record.completed_at = result.completed_at
record.updated_at = result.completed_at or time.time()
@staticmethod
def _capability(
subagent_id: str, parent_session_id: Optional[str], created_at: float
) -> str:
value = f"{subagent_id}|{parent_session_id or ''}|{created_at:.6f}".encode()
return hmac.new(_SECRET, value, hashlib.sha256).hexdigest()
@staticmethod
def _validate_request(request: SubagentLaunchRequest, parent: Any) -> None:
if (
not isinstance(request, SubagentLaunchRequest)
or not isinstance(request.goal, str)
or not request.goal.strip()
or len(request.goal) > _MAX_GOAL_CHARS
):
raise SubagentLifecycleError(
"goal must be a non-empty string of at most 16000 characters."
)
if request.context is not None and (
not isinstance(request.context, str)
or len(request.context) > _MAX_CONTEXT_CHARS
):
raise SubagentLifecycleError(
"context must be a string of at most 32000 characters."
)
if request.role not in {"leaf", "orchestrator"}:
raise SubagentLifecycleError("role must be 'leaf' or 'orchestrator'.")
if request.timeout_seconds is not None:
raise SubagentLifecycleError(
"Per-launch timeout is not supported; configure delegation timeout explicitly."
)
if request.working_directory is not None:
raise SubagentLifecycleError(
"working_directory is not supported because Hermes delegates use isolated task environments."
)
if request.blocked_tools:
raise SubagentLifecycleError(
"Per-tool blocking is not supported; use allowed_toolsets. Hermes always blocks unsafe child tools."
)
try:
metadata_bytes = len(
json.dumps(dict(request.metadata), sort_keys=True).encode()
)
except (TypeError, ValueError) as exc:
raise SubagentLifecycleError("metadata must be JSON-serializable.") from exc
if metadata_bytes > _MAX_METADATA_BYTES:
raise SubagentLifecycleError("metadata exceeds 8192 bytes.")
if request.allowed_toolsets:
from toolsets import TOOLSETS
unknown = set(request.allowed_toolsets) - set(TOOLSETS)
if unknown:
raise SubagentLifecycleError(
f"Unknown toolsets: {', '.join(sorted(unknown))}."
)
enabled = getattr(parent, "enabled_toolsets", None)
if enabled is not None and not set(request.allowed_toolsets).issubset(
set(enabled)
):
raise SubagentLifecycleError(
"Requested toolsets would broaden parent permissions."
)

View file

@ -26,6 +26,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
from __future__ import annotations
import json
import logging
import os
from typing import Any, Dict, List, Optional
@ -51,6 +52,8 @@ from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
logger = logging.getLogger(__name__)
def _ra():
"""Lazy reference to the ``run_agent`` module.
@ -582,6 +585,60 @@ def invalidate_system_prompt(agent: Any) -> None:
agent._memory_store.load_from_disk()
def reconstruct_static_prefix(
agent: Any,
system_message: Optional[str] = None,
*,
log_label: str = "restore",
) -> None:
"""Reconstruct ``_cached_system_prompt_static`` for a stored prompt.
The static prefix is not persisted (only the full prompt is), so any
path that adopts a stored/kept ``_cached_system_prompt`` session
restore, the compression keep-prompt path, or a failover to a cache-on
provider mid-turn (#72626) — must rebuild the stable tier to regain the
two-block ``[static, volatile]`` system layout.
Safety: the rebuilt stable tier is used ONLY when the stored prompt
literally starts with it (checked here AND re-checked by
``_apply_system_cache_markers``'s ``startswith`` gate). If any
stable-tier input changed since the prompt was persisted (skills
edited, identity changed), the prefix mismatches, the static stays
None, and requests fall back to the legacy layout with the stored
prompt bytes untouched never a rewritten prompt.
A failed reconstruction is memoized per stored prompt
(``_static_rebuild_failed_for``): ``build_system_prompt_parts`` does
real file I/O (SOUL.md, context files, memory), and callers on the
retry-loop hot path must not re-run it every attempt when the inputs
haven't changed. A legitimately changed stored prompt retries once.
"""
if not getattr(agent, "_use_prompt_caching", False):
return
stored = getattr(agent, "_cached_system_prompt", None)
if not isinstance(stored, str) or not stored:
return
existing = getattr(agent, "_cached_system_prompt_static", None)
if isinstance(existing, str) and existing and stored.startswith(existing):
return
if getattr(agent, "_static_rebuild_failed_for", None) == stored:
return
try:
static = build_system_prompt_parts(agent, system_message=system_message)["stable"]
if static and stored.startswith(static):
agent._cached_system_prompt_static = static
agent._static_rebuild_failed_for = None
return
except Exception:
logger.debug(
"static system-prefix reconstruction failed on %s",
log_label,
exc_info=True,
)
agent._cached_system_prompt_static = None
agent._static_rebuild_failed_for = stored
def format_tools_for_system_message(agent: Any) -> str:
"""Format tool definitions for the system message in the trajectory format.

View file

@ -140,8 +140,8 @@ def _flush_session_db_after_tool_progress(
messages: list,
*,
stage: str,
) -> None:
"""Best-effort incremental SessionDB flush for tool-call progress.
) -> bool:
"""Flush tool-call progress before projecting it to any UI surface.
Tool execution can perform side effects that terminate or restart the
current Hermes process before the normal turn-end persistence path runs.
@ -149,9 +149,14 @@ def _flush_session_db_after_tool_progress(
transcript survives destructive-but-valid tool calls.
"""
try:
agent._flush_messages_to_session_db(messages)
persisted = agent._flush_messages_to_session_db(messages) is not False
if not persisted:
agent._incremental_persistence_failed = True
return persisted
except Exception as exc:
agent._incremental_persistence_failed = True
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
return False
def _ra():
@ -431,8 +436,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
if not _err and _underlying:
if _underlying in _tool_search_scoped_names(agent):
function_name = _underlying
function_args = _underlying_args
# Probe-validate before unwrapping (ironclaw#5149):
# missing required args return the parameter schema
# instead of dispatching into an opaque failure.
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
if _probe_err is not None:
_ts_scope_block = _probe_err
else:
function_name = _underlying
function_args = _underlying_args
else:
_ts_scope_block = json.dumps({
"error": (
@ -854,6 +866,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls):
r = results[i]
blocked = False
is_error = True
progress_function_name = name
# A worker can finish and write results[i] in the window between the
# deadline snapshot (timed_out_indices, taken from not_done) and this
# loop. Prefer that real result over a fabricated timeout message — the
@ -909,6 +923,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
tool_duration = 0.0
else:
function_name, function_args, function_result, tool_duration, is_error, blocked, middleware_trace = r
progress_function_name = function_name
if blocked:
effect_disposition = "none"
@ -936,44 +951,15 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
except Exception as _ver_err:
logging.debug("file-mutation verifier record failed: %s", _ver_err)
if not blocked and agent.tool_progress_callback:
try:
agent.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=is_error,
result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
if agent.verbose_logging:
logging.debug(f"Tool {function_name} completed in {tool_duration:.2f}s")
logging.debug(f"Tool result ({len(function_result)} chars): {function_result}")
# Print cute message per tool
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
print(agent._wrap_verbose("Result: ", _preview_str))
else:
response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}")
agent._current_tool = None
_status_suffix = " (error)" if is_error else ""
agent._touch_activity(f"tool completed: {name} ({tool_duration:.1f}s){_status_suffix}")
if not blocked and agent.tool_complete_callback:
try:
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_complete_callback(tc.id, name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
display_function_result = function_result
function_result = maybe_persist_tool_result(
content=function_result,
tool_name=name,
@ -1008,6 +994,50 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if not _flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {name}",
):
return
# Every completion surface is downstream of the canonical append. If
# the UI bridge or process dies while projecting one of these events,
# resume can reconstruct the tool result that was already visible.
if not blocked and agent.tool_progress_callback:
try:
agent.tool_progress_callback(
"tool.completed", progress_function_name, None, None,
duration=tool_duration, is_error=is_error,
result=display_function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
# Print cute message per tool
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(
name, args, tool_duration, result=display_function_result,
)
agent._safe_print(f" {cute_msg}")
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
_preview_str = _multimodal_text_summary(display_function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
print(agent._wrap_verbose("Result: ", _preview_str))
else:
response_preview = _preview_str[:agent.log_prefix_chars] + "..." if len(_preview_str) > agent.log_prefix_chars else _preview_str
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s - {response_preview}")
if not blocked and agent.tool_complete_callback:
try:
display_args = _redact_tool_args_for_display(name, args) or args
agent.tool_complete_callback(
tc.id, name, display_args, display_function_result,
)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
@ -1024,11 +1054,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Same as the sequential path: drain between each collected
@ -1060,6 +1085,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
# Resolve the context-scaled tool-output budget once per turn.
_tool_budget = _budget_for_agent(agent)
for i, tool_call in enumerate(assistant_message.tool_calls, 1):
if getattr(agent, "_incremental_persistence_failed", False):
return
# SAFETY: check interrupt BEFORE starting each tool.
# If the user sent "stop" during a previous tool's execution,
# do NOT start any more tools -- skip them all immediately.
@ -1075,11 +1102,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
skipped_tc.id,
effect_disposition="none",
))
_flush_session_db_after_tool_progress(
if not _flush_session_db_after_tool_progress(
agent,
messages,
stage=f"cancelled tool result {skipped_name}",
)
):
return
break
function_name = tool_call.function.name
@ -1095,11 +1123,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_call.id,
)
)
_flush_session_db_after_tool_progress(
if not _flush_session_db_after_tool_progress(
agent,
messages,
stage=f"invalid tool arguments {function_name}",
)
):
return
agent._apply_pending_steer_to_tool_results(messages, 1)
continue
@ -1113,8 +1142,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args)
if not _err and _underlying:
if _underlying in _tool_search_scoped_names(agent):
function_name = _underlying
function_args = _underlying_args
# Probe-validate before unwrapping (ironclaw#5149):
# missing required args return the parameter schema
# instead of dispatching into an opaque failure.
_probe_err = _ts.validate_deferred_call_args(_underlying, _underlying_args)
if _probe_err is not None:
# This path wraps _block_msg in {"error": ...} —
# flatten the probe payload to one plain string.
try:
_probe = json.loads(_probe_err)
_ts_scope_block = (
f"{_probe.get('error', '')} Parameters schema: "
f"{json.dumps(_probe.get('parameters', {}), ensure_ascii=False)}. "
f"{_probe.get('hint', '')}"
).strip()
except Exception:
_ts_scope_block = _probe_err
else:
function_name = _underlying
function_args = _underlying_args
else:
_ts_scope_block = (
f"'{_underlying}' is not available in this session. "
@ -1360,6 +1406,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
return _clarify_tool(
question=next_args.get("question", ""),
choices=next_args.get("choices"),
multi_select=next_args.get("multi_select", False),
callback=agent.clarify_callback,
)
function_result, function_args = _run_agent_tool_execution_middleware(
@ -1645,16 +1692,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
except Exception as _ver_err:
logging.debug("file-mutation verifier record failed: %s", _ver_err)
if not _execution_blocked and agent.tool_progress_callback:
try:
agent.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=_is_error_result,
result=function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
agent._current_tool = None
_status_suffix = " (error)" if _is_error_result else ""
agent._touch_activity(f"tool completed: {function_name} ({tool_duration:.1f}s){_status_suffix}")
@ -1664,13 +1701,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
_log_result = _multimodal_text_summary(function_result)
logging.debug(f"Tool result ({len(_log_result)} chars): {_log_result}")
if not _execution_blocked and agent.tool_complete_callback:
try:
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
agent.tool_complete_callback(tool_call.id, function_name, display_args, function_result)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
display_function_result = function_result
function_result = maybe_persist_tool_result(
content=function_result,
tool_name=function_name,
@ -1693,6 +1724,40 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_message = make_tool_result_message(function_name, _tool_content, tool_call.id)
messages.append(tool_message)
risk_metadata = tool_message.get("_tool_output_risk")
if not _flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {function_name}",
):
return
# UI completion/progress events are projections of the canonical tool
# row, never a competing in-memory authority.
if not _execution_blocked and agent.tool_progress_callback:
try:
agent.tool_progress_callback(
"tool.completed", function_name, None, None,
duration=tool_duration, is_error=_is_error_result,
result=display_function_result,
)
except Exception as cb_err:
logging.debug(f"Tool progress callback error: {cb_err}")
if not _execution_blocked and agent.tool_complete_callback:
try:
display_args = (
_redact_tool_args_for_display(function_name, function_args)
or function_args
)
agent.tool_complete_callback(
tool_call.id,
function_name,
display_args,
display_function_result,
)
except Exception as cb_err:
logging.debug(f"Tool complete callback error: {cb_err}")
if (
risk_metadata is not None
and risk_metadata.get("risk") != "low"
@ -1709,11 +1774,6 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
)
except Exception as cb_err:
logging.debug("Tool output risk callback error: %s", cb_err)
_flush_session_db_after_tool_progress(
agent,
messages,
stage=f"tool result {function_name}",
)
# ── Per-tool /steer drain ───────────────────────────────────
# Drain pending steer BETWEEN individual tool calls so the
@ -1741,11 +1801,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
skipped_tc.id,
effect_disposition="none",
))
_flush_session_db_after_tool_progress(
if not _flush_session_db_after_tool_progress(
agent,
messages,
stage=f"skipped tool result {skipped_name}",
)
):
return
break
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
@ -1796,6 +1857,8 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
segments = _plan_tool_batch_segments(assistant_message.tool_calls, execution_cwd=_exec_cwd)
for kind, calls in segments:
if getattr(agent, "_incremental_persistence_failed", False):
return
segment_message = SimpleNamespace(tool_calls=list(calls))
if kind == "parallel":
execute_tool_calls_concurrent(
@ -1808,6 +1871,9 @@ def execute_tool_calls_segmented(agent, assistant_message, messages: list, effec
finalize=False,
)
if getattr(agent, "_incremental_persistence_failed", False):
return
# ── Whole-turn finalize (budget + /steer) ─────────────────────────
total_tools = len(assistant_message.tool_calls)
if total_tools > 0:

View file

@ -79,6 +79,7 @@ class ToolCallGuardrailConfig:
no_progress_block_after: int = 5
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
loop_caps: "LoopCapConfig" = field(default_factory=lambda: LoopCapConfig())
@classmethod
def from_mapping(cls, data: Mapping[str, Any] | None) -> "ToolCallGuardrailConfig":
@ -121,6 +122,54 @@ class ToolCallGuardrailConfig:
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
defaults.no_progress_block_after,
),
loop_caps=LoopCapConfig.from_mapping(data.get("loop_caps")),
)
# Default session-wide caps, matching Claude Code's v2.1.212 runaway-loop
# Per-turn (per-agent-loop) caps on runaway-prone tool calls. Counts reset at
# the start of every agent loop (reset_for_turn), so the limit is "within a
# single turn" rather than cumulative over the whole session. A single loop
# issuing dozens of web searches or spawning dozens of subagents is already
# pathological, so the defaults are deliberately low.
_DEFAULT_MAX_WEB_SEARCHES_PER_TURN = 50
_DEFAULT_MAX_SUBAGENTS_PER_TURN = 50
@dataclass(frozen=True)
class LoopCapConfig:
"""Per-turn caps on runaway-prone tool calls.
Inspired by Claude Code v2.1.212 (Week 29, July 2026), which added caps on
WebSearch calls and subagent spawns to stop runaway search / delegation
loops. Here the caps count *within a single agent loop* (one turn): the
counters reset in ``reset_for_turn`` at the start of every
``run_conversation``, so a legitimate multi-turn session is never starved,
but a single turn that spirals into an unbounded search / delegation loop
is stopped.
Semantics differ from the per-turn loop *detector* above (which keys on
repeated identical/failing calls): these caps are a hard ceiling on the
total count of a tool within the turn and fire regardless of
``hard_stop_enabled``. A value of ``0`` disables the cap (unlimited).
"""
max_web_searches: int = _DEFAULT_MAX_WEB_SEARCHES_PER_TURN
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
@classmethod
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
if not isinstance(data, Mapping):
return cls()
defaults = cls()
return cls(
max_web_searches=_non_negative_int(
data.get("max_web_searches"), defaults.max_web_searches
),
max_subagents=_non_negative_int(
data.get("max_subagents"), defaults.max_subagents
),
)
@ -233,6 +282,11 @@ class ToolCallGuardrailController:
self._same_tool_failure_counts: dict[str, int] = {}
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
self._halt_decision: ToolGuardrailDecision | None = None
# 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.
self._turn_web_search_count = 0
self._turn_subagent_count = 0
@property
def halt_decision(self) -> ToolGuardrailDecision | None:
@ -240,6 +294,17 @@ class ToolCallGuardrailController:
def before_call(self, tool_name: str, args: Mapping[str, Any] | None) -> ToolGuardrailDecision:
signature = ToolCallSignature.from_call(tool_name, _coerce_args(args))
# ── Per-turn runaway-loop caps ──────────────────────────────────
# These are hard ceilings on how many times a runaway-prone tool may
# be called within a single agent loop (turn). They apply regardless
# of hard_stop_enabled (which only governs the per-turn loop detector).
# We block BEFORE the call runs once the count is already at the cap,
# then increment for an allowed call so the (cap+1)-th is refused.
cap_block = self._check_loop_cap(tool_name, _coerce_args(args), signature)
if cap_block is not None:
return cap_block
if not self.config.hard_stop_enabled:
return ToolGuardrailDecision(tool_name=tool_name, signature=signature)
@ -379,6 +444,68 @@ class ToolCallGuardrailController:
return False
return tool_name in self.config.idempotent_tools
def _check_loop_cap(
self,
tool_name: str,
args: Mapping[str, Any],
signature: ToolCallSignature,
) -> ToolGuardrailDecision | None:
"""Enforce and advance the per-turn runaway-loop counters.
Returns a ``block`` decision when the cap is already reached, otherwise
increments the relevant counter for the allowed call and returns
``None``. A cap of 0 disables that limit entirely. Counters reset each
turn via ``reset_for_turn``.
"""
caps = self.config.loop_caps
if tool_name == "web_search":
cap = caps.max_web_searches
if cap and self._turn_web_search_count >= cap:
decision = ToolGuardrailDecision(
action="block",
code="loop_web_search_cap",
message=(
f"Blocked web_search: this turn has already made {cap} "
"web searches, the per-turn limit. This looks like a "
"runaway search loop. Work with the results you already "
"have and give the user your answer."
),
tool_name=tool_name,
count=self._turn_web_search_count,
signature=signature,
)
self._halt_decision = decision
return decision
self._turn_web_search_count += 1
return None
if tool_name == "delegate_task":
cap = caps.max_subagents
if not cap:
return None
spawn_count = _subagent_spawn_count(args)
if self._turn_subagent_count >= cap:
decision = ToolGuardrailDecision(
action="block",
code="loop_subagent_cap",
message=(
f"Blocked delegate_task: this turn has already spawned "
f"{self._turn_subagent_count} subagents (limit {cap}). "
"This looks like a runaway delegation loop. Finish the "
"work with the results you have and answer the user."
),
tool_name=tool_name,
count=self._turn_subagent_count,
signature=signature,
)
self._halt_decision = decision
return decision
self._turn_subagent_count += spawn_count
return None
return None
def toolguard_synthetic_result(decision: ToolGuardrailDecision) -> str:
"""Build a synthetic role=tool content string for a blocked tool call."""
@ -471,6 +598,32 @@ def _positive_int(value: Any, default: int) -> int:
return parsed if parsed >= 1 else default
def _non_negative_int(value: Any, default: int) -> int:
"""Parse a session-cap value. 0 is a valid (disable) value; negatives and
junk fall back to the default."""
if value is None:
return default
try:
parsed = int(value)
except (TypeError, ValueError):
return default
return parsed if parsed >= 0 else default
def _subagent_spawn_count(args: Mapping[str, Any]) -> int:
"""How many subagents a single delegate_task call spawns.
delegate_task runs in one of two modes: a batch (``tasks`` is a non-empty
list, one child per item) or a single task (``goal``). Count the batch size
when present, otherwise 1, so the session subagent cap reflects real spawns
rather than delegate_task invocations.
"""
tasks = args.get("tasks") if isinstance(args, Mapping) else None
if isinstance(tasks, list) and tasks:
return len(tasks)
return 1
def _sha256(value: str) -> str:
# surrogatepass: tool results scraped from the web can carry unpaired
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict

View file

@ -336,6 +336,8 @@ def build_turn_context(
persist_user_message: Optional[Any],
persist_user_timestamp: Optional[float] = None,
*,
persist_user_display_kind: Optional[str] = None,
persist_user_display_metadata: Optional[Dict[str, Any]] = None,
restore_or_build_system_prompt,
install_safe_stdio,
sanitize_surrogates,
@ -537,6 +539,19 @@ def build_turn_context(
# Add the current user message after the prompt/session setup has made
# close persistence safe. The handoff above preserves any marker already
# stamped by an earlier close flush.
#
# A synthesized turn (auto-continue recovery note, delegation completion)
# declares how it should READ in a transcript. Stamp that on the live
# message so the crash persist below writes the row already typed. Typing
# it after the turn instead leaves the row untyped for the whole run — and
# forever if the turn crashes — so the raw system note paints as a user
# bubble. The model still receives role/content unchanged; the api_messages
# build strips both fields from every outgoing copy.
if persist_user_display_kind:
user_msg["display_kind"] = persist_user_display_kind
if persist_user_display_metadata:
user_msg["display_metadata"] = persist_user_display_metadata
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx

310
agent/turn_summary.py Normal file
View file

@ -0,0 +1,310 @@
"""Per-turn accounting for the interactive CLI.
Two display-only pieces live here:
* :class:`TurnSummaryCollector` a tiny observer that rides the existing
``tool_progress_callback`` feed (``tool.completed`` events already carry
the tool name and its raw result) and tallies what a turn actually did.
It holds **no** agent-loop state: the display layer already sees every
tool call, so nothing new is threaded through the conversation loop.
* :func:`format_turn_summary` a pure formatter that turns a tally plus a
wall-clock duration into one dim line, e.g.::
12.4s · edited 2 files +18 -3 · read 4 files · ran 3 commands
Ported from Claude Code's post-turn accounting line
("Edited 1 file +6 -2, read 1 file … Worked for 10s").
:func:`format_token_flow` is the spinner-side counterpart: a cumulative
token readout appended to the live elapsed timer (`` 1.2k tok``).
Everything in this module is pure/side-effect free apart from the
collector's own counters, which makes it directly unit-testable without a
terminal, an agent, or a network call.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
__all__ = [
"TurnSummaryCollector",
"TurnTally",
"format_turn_summary",
"format_token_flow",
"format_elapsed",
]
# Leading glyph for the summary line. Deliberately not an emoji — the line is
# meant to read as terminal chrome, not as agent speech.
SUMMARY_PREFIX = ""
# A turn that called no tools and finished this fast has nothing worth
# reporting (plain chat reply). Below the threshold the formatter returns "".
_MIN_TOOLLESS_SECONDS = 2.0
# Max number of "verb + count" segments rendered before collapsing the rest
# into a "+N more" tail, so a 12-tool turn cannot blow past one line.
_MAX_SEGMENTS = 4
# Tool name -> (verb, singular noun, plural noun).
#
# Verbs are past tense because the line is printed *after* the turn. Tools not
# listed here fall into a generic "called N tools" bucket rather than inventing
# phrasing for plugin/MCP tools whose semantics we don't know.
_VERB_GROUPS: dict[str, tuple[str, str, str]] = {
"write_file": ("edited", "file", "files"),
"patch": ("edited", "file", "files"),
"read_file": ("read", "file", "files"),
"web_extract": ("read", "page", "pages"),
"terminal": ("ran", "command", "commands"),
"execute_code": ("ran", "script", "scripts"),
"search_files": ("searched", "path", "paths"),
"web_search": ("searched the web", "time", "times"),
"session_search": ("searched sessions", "time", "times"),
"browser_navigate": ("browsed", "page", "pages"),
"skill_view": ("read", "skill", "skills"),
"skill_manage": ("updated", "skill", "skills"),
"skills_list": ("listed skills", "time", "times"),
"todo": ("updated", "task list", "task lists"),
"delegate_task": ("delegated", "task", "tasks"),
"memory": ("updated", "memory", "memories"),
}
# Verb groups that carry file-edit line deltas (+X -Y) when known.
_EDIT_VERB = "edited"
# Render order: edits first (the thing users most want confirmed), then reads,
# then commands. Anything else follows in first-seen order.
_VERB_PRIORITY: tuple[str, ...] = ("edited", "read", "ran")
# Tools whose results may report a unified diff we can count lines from.
_DIFF_RESULT_TOOLS = frozenset({"patch"})
@dataclass
class TurnTally:
"""What a single turn did, as observed from the tool-progress feed."""
# verb -> {noun_plural: count}; keeps insertion order for stable rendering.
verbs: dict[str, dict[str, int]] = field(default_factory=dict)
# Tools with no curated verb, counted together.
other_tools: int = 0
# Aggregated unified-diff line deltas across edit tools, when reported.
lines_added: int = 0
lines_removed: int = 0
# True once at least one edit tool reported a countable diff, so the
# formatter knows the difference between "+0 -0" and "unknown".
has_line_deltas: bool = False
@property
def total_tools(self) -> int:
counted = sum(sum(nouns.values()) for nouns in self.verbs.values())
return counted + self.other_tools
def _count_diff_lines(diff: str) -> tuple[int, int]:
"""Count added/removed lines in unified-diff text.
File headers (``+++``/``---``) are excluded so a one-line edit does not
read as three additions.
"""
added = removed = 0
for line in diff.splitlines():
if line.startswith("+++") or line.startswith("---"):
continue
if line.startswith("+"):
added += 1
elif line.startswith("-"):
removed += 1
return added, removed
def _extract_line_deltas(tool_name: str, result: Any) -> tuple[int, int] | None:
"""Pull (added, removed) from a tool result, or None when unavailable.
Only tools that already report a diff in their result payload are
inspected we never shell out to git and never re-read files to
synthesise a delta.
"""
if tool_name not in _DIFF_RESULT_TOOLS:
return None
payload: Any = result
if isinstance(payload, str):
text = payload.strip()
if not text.startswith("{"):
return None
try:
import json
# strict=False tolerates literal control characters inside strings
# (raw newlines in an embedded diff), which some tool serialisers
# emit. A tally line is never worth failing over formatting.
payload = json.loads(text, strict=False)
except Exception:
return None
if not isinstance(payload, dict):
return None
diff = payload.get("diff")
if not isinstance(diff, str) or not diff.strip():
return None
added, removed = _count_diff_lines(diff)
# A diff that carries no +/- content lines (e.g. a bare hunk header) tells
# us nothing — report it as unknown rather than rendering a misleading
# "+0 -0" next to a real edit.
if added == 0 and removed == 0:
return None
return added, removed
class TurnSummaryCollector:
"""Accumulate per-turn tool tallies from the tool-progress feed.
Wired into the CLI's existing ``_on_tool_progress`` handler: the display
layer already receives every ``tool.completed`` event with the tool name
and raw result, so no agent-loop bookkeeping is added.
"""
def __init__(self) -> None:
self._tally = TurnTally()
def begin(self) -> None:
"""Start a fresh turn (drops any prior tally)."""
self._tally = TurnTally()
def record_tool(
self,
tool_name: str | None,
*,
result: Any = None,
is_error: bool = False,
) -> None:
"""Record one completed tool call.
Failed calls are skipped: a summary claiming "edited 2 files" when one
write was denied would be exactly the over-claim the file-mutation
verifier exists to catch.
"""
if not tool_name or is_error:
return
# Internal/pseudo tools (``_thinking``) are not user-visible work.
if tool_name.startswith("_"):
return
group = _VERB_GROUPS.get(tool_name)
if group is None:
self._tally.other_tools += 1
return
verb, _singular, plural = group
nouns = self._tally.verbs.setdefault(verb, {})
nouns[plural] = nouns.get(plural, 0) + 1
if verb == _EDIT_VERB:
deltas = _extract_line_deltas(tool_name, result)
if deltas is not None:
added, removed = deltas
self._tally.lines_added += added
self._tally.lines_removed += removed
self._tally.has_line_deltas = True
@property
def tally(self) -> TurnTally:
return self._tally
def render(self, elapsed_seconds: float) -> str:
"""Render this turn's summary line (see :func:`format_turn_summary`)."""
return format_turn_summary(elapsed_seconds, self._tally)
def format_elapsed(seconds: float) -> str:
"""Format a wall-clock duration compactly (``12.4s`` / ``2m05s``)."""
if seconds < 0:
seconds = 0.0
if seconds < 60:
return f"{seconds:.1f}s"
minutes, rest = divmod(int(round(seconds)), 60)
return f"{minutes}m{rest:02d}s"
def _pluralize(count: int, plural_noun: str) -> str:
"""Return ``"1 file"`` / ``"3 files"`` from a plural noun form."""
if count == 1:
singular = plural_noun
if plural_noun.endswith("ies"):
singular = plural_noun[:-3] + "y"
elif plural_noun.endswith("ses"):
singular = plural_noun[:-2]
elif plural_noun.endswith("s"):
singular = plural_noun[:-1]
return f"1 {singular}"
return f"{count} {plural_noun}"
def _ordered_verbs(tally: TurnTally) -> list[str]:
"""Verbs in render order: priority verbs first, then first-seen order."""
seen = list(tally.verbs.keys())
ranked = [v for v in _VERB_PRIORITY if v in tally.verbs]
ranked += [v for v in seen if v not in _VERB_PRIORITY]
return ranked
def format_turn_summary(
elapsed_seconds: float,
tally: TurnTally | None,
*,
max_segments: int = _MAX_SEGMENTS,
) -> str:
"""Render the per-turn accounting line, or ``""`` when there's nothing to say.
Pure function no config lookups, no terminal access, no I/O. Gating
(``display.turn_summary``, quiet mode, CLI-only) is the caller's job.
"""
if tally is None:
tally = TurnTally()
segments: list[str] = []
for verb in _ordered_verbs(tally):
nouns = tally.verbs[verb]
parts = [_pluralize(count, plural) for plural, count in nouns.items() if count]
if not parts:
continue
segment = f"{verb} {', '.join(parts)}"
if verb == _EDIT_VERB and tally.has_line_deltas:
segment += f" +{tally.lines_added} -{tally.lines_removed}"
segments.append(segment)
if tally.other_tools:
segments.append(f"called {_pluralize(tally.other_tools, 'tools')}")
if not segments and tally.total_tools == 0 and elapsed_seconds < _MIN_TOOLLESS_SECONDS:
return ""
if max_segments > 0 and len(segments) > max_segments:
hidden = len(segments) - max_segments
segments = segments[:max_segments] + [f"+{hidden} more"]
pieces = [format_elapsed(elapsed_seconds)] + segments
return f"{SUMMARY_PREFIX} " + " · ".join(pieces)
def format_token_flow(output_tokens: Any, *, arrow: str = "") -> str:
"""Render cumulative turn tokens for the live spinner (``↓ 1.2k tok``).
Returns ``""`` for a non-positive count so the spinner shows nothing
rather than a misleading `` 0 tok`` before the first API response lands.
"""
try:
count = int(output_tokens)
except (TypeError, ValueError):
return ""
if count <= 0:
return ""
if count < 1000:
return f"{arrow} {count} tok"
if count < 1_000_000:
return f"{arrow} {count / 1000:.1f}k tok"
return f"{arrow} {count / 1_000_000:.1f}M tok"

View file

@ -12,11 +12,11 @@
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use tokio::sync::{mpsc, Mutex};
@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
&& resolve_hermes_desktop_exe(install_root).is_some()
}
fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option<String> {
if let Some(commit) = pin
.commit
.as_ref()
.filter(|commit| !commit.trim().is_empty())
{
return Some(commit.clone());
}
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(install_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
if commit.is_empty() {
None
} else {
Some(commit)
}
}
fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> {
use std::io::Write;
let marker_path = crate::paths::likely_bootstrap_marker(install_root);
if let Some(parent) = marker_path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"could not create bootstrap marker directory {}",
parent.display()
)
})?;
}
let completed_at_unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let marker = serde_json::json!({
"schemaVersion": 1,
"pinnedCommit": resolve_marker_commit(install_root, pin),
"pinnedBranch": pin.branch.clone(),
"completedAtUnix": completed_at_unix,
});
let mut body = serde_json::to_vec_pretty(&marker)?;
body.push(b'\n');
// Atomic publish (temp sibling + flush + rename), matching Electron's
// writeFileAtomic(). hermes_is_installed() only checks existence, so a
// partial direct write would incorrectly enable the launcher fast path.
let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp");
{
let mut file = std::fs::File::create(&tmp_path).with_context(|| {
format!(
"could not create temp bootstrap marker {}",
tmp_path.display()
)
})?;
file.write_all(&body).with_context(|| {
format!(
"could not write temp bootstrap marker {}",
tmp_path.display()
)
})?;
file.sync_all().with_context(|| {
format!(
"could not flush temp bootstrap marker {}",
tmp_path.display()
)
})?;
}
// Windows rename fails if the destination already exists; drop any prior
// marker first so a re-run can still publish a fresh payload.
if marker_path.exists() {
std::fs::remove_file(&marker_path).with_context(|| {
format!(
"could not replace existing bootstrap marker {}",
marker_path.display()
)
})?;
}
if let Err(err) = std::fs::rename(&tmp_path, &marker_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(err).with_context(|| {
format!(
"could not publish bootstrap marker {} → {}",
tmp_path.display(),
marker_path.display()
)
});
}
tracing::info!(path = %marker_path.display(), "bootstrap marker written");
Ok(marker)
}
/// Spawn the already-built desktop app, detached. Returns Err if no built app
/// exists or the spawn fails, so the caller can fall back to showing the
/// installer UI.
@ -644,6 +745,23 @@ async fn run_bootstrap(
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
// Marker publish is terminal for this run: a write failure must emit Failed
// so the UI leaves the progress state (it does not poll get_bootstrap_status).
let marker = match write_bootstrap_complete_marker(&install_root, &pin) {
Ok(marker) => marker,
Err(err) => {
let msg = format!("write bootstrap marker failed: {err:#}");
emit_event(
&app,
BootstrapEvent::Failed {
stage: None,
error: msg.clone(),
},
);
return Err(anyhow!(msg));
}
};
// Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can
// re-invoke us with `--update` and shortcuts have a stable target. This is
// a one-shot install concern; an `--update` re-invocation no-ops because
@ -660,10 +778,7 @@ async fn run_bootstrap(
&app,
BootstrapEvent::Complete {
install_root: install_root.to_string_lossy().into_owned(),
marker: Some(serde_json::json!({
"pinnedCommit": pin.commit,
"pinnedBranch": pin.branch,
})),
marker: Some(marker),
},
);
@ -903,4 +1018,103 @@ mod tests {
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn bootstrap_complete_marker_uses_desktop_compatible_schema() {
let root = unique_tmp_dir("marker-schema");
let pin = Pin {
commit: Some("abcdef1234567890".to_string()),
branch: Some("main".to_string()),
};
let marker =
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
let marker_path = root.join(".hermes-bootstrap-complete");
let from_disk: serde_json::Value =
serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
assert_eq!(marker, from_disk);
assert_eq!(from_disk["schemaVersion"], 1);
assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890");
assert_eq!(from_disk["pinnedBranch"], "main");
assert!(
from_disk["completedAtUnix"].as_u64().is_some(),
"marker must carry a completion timestamp"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn bootstrap_complete_marker_is_published_atomically() {
let root = unique_tmp_dir("marker-atomic");
make_release_tree(&root);
let pin = Pin {
commit: Some("abcdef1234567890".to_string()),
branch: Some("main".to_string()),
};
write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed");
let marker_path = root.join(".hermes-bootstrap-complete");
let tmp_path = root.join(".hermes-bootstrap-complete.tmp");
assert!(
marker_path.is_file(),
"final marker must exist after atomic publish"
);
assert!(
!tmp_path.exists(),
"temp sibling must not remain after atomic publish"
);
assert!(
hermes_is_installed(&root),
"atomically published marker must enable the installer fast path"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn hermes_is_installed_treats_marker_existence_as_sufficient() {
// Documents why write_bootstrap_complete_marker must publish atomically:
// the launcher predicate only checks existence, so a partial/corrupt
// final marker would still enable the fast path.
let root = unique_tmp_dir("marker-existence-only");
make_release_tree(&root);
std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap();
assert!(
hermes_is_installed(&root),
"empty/partial marker content still counts as installed"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn marker_write_failure_leaves_no_final_marker() {
// install_root is a regular file → create_dir_all on its path fails
// before any marker bytes are published under the final name.
let base = unique_tmp_dir("marker-fail");
let not_a_dir = base.join("not-a-dir");
std::fs::write(&not_a_dir, b"not a directory").unwrap();
let pin = Pin {
commit: Some("abcdef1234567890".to_string()),
branch: Some("main".to_string()),
};
let err = write_bootstrap_complete_marker(&not_a_dir, &pin)
.expect_err("marker write against a non-directory root must fail");
let msg = format!("{err:#}");
assert!(
msg.contains("bootstrap marker"),
"error should mention the marker path: {msg}"
);
assert!(
!not_a_dir.join(".hermes-bootstrap-complete").exists(),
"failed write must not leave a final marker that enables the fast path"
);
assert!(
!not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(),
"failed write must not leave a temp marker sibling either"
);
let _ = std::fs::remove_dir_all(&base);
}
}

View file

@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) {
#[cfg(not(target_os = "macos"))]
fn repair_macos_installer_helper(_path: &Path) {}
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
/// the Electron app also checks). Per main.ts:
/// Where the bootstrap-complete marker lives (existence-only for the Rust
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
/// this is a probe helper, not a definitive path.

View file

@ -117,20 +117,31 @@ that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
`icon-sm` / `icon-lg` / `icon-titlebar`.
**Icon-only buttons must have a tooltip.** Every button with an `icon*` size
carries no visible text label, so it must be wrapped in `<Tip label={...}>`
with a descriptive label (matching the button's `aria-label`). Never use the
native HTML `title=` attribute — it's unstyled, delayed (~500ms OS default),
and visually inconsistent with the instant themed `Tip`. An enforcement test
(`src/components/ui/__tests__/no-native-title.test.ts`) fails on any `<button>`
or `<Button>` that still carries `title=`.
**Tooltips only when hover teaches something new.** `<Tip>` is for discovery,
not a tax on every icon. Ask: does hover reveal something the user cannot
already see or infer? If not, skip the tip; keep an `aria-label` for a11y.
**Keybind hints in tooltips.** When a button corresponds to a rebindable
hotkey, use `<TipKeybindLabel actionId="..." />` as the `Tip` label — it
auto-reads both the i18n label and the current keybind combo from the store,
so the hint stays live when the user rebinds. Pass `text={...}` only when the
tooltip is context-dependent (e.g. "Show" / "Hide" based on state). Never
hardcode combos in components — always read from the `$bindings` store via
Tip unlabeled chrome when the job (or a keybind / truncated path / host /
other detail) is not already on screen — toolbar / titlebar / statusbar icons,
`TipKeybindLabel` shortcuts, ownership chips, unlabeled icon grids.
Do **not** tip:
- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the
affordance is "open menu"; verbs live in the menu. Never tip
`"Actions for ${row title}"` / `"Project actions"` / `"Actions"`.
- Close / dismiss X buttons — the glyph is the label (`aria-label` only).
- Controls whose visible label already says what the tip would ("click to…",
paraphrases of the same words, timer labels restating "Running").
Never use native HTML `title=` on buttons — unstyled, ~500ms OS delay, clashes
with the themed `Tip`. `src/components/ui/__tests__/no-native-title.test.ts`
fails on any `<button>` / `<Button>` that still carries `title=`.
**Keybind hints in tooltips.** On a tipped button bound to a rebindable hotkey,
use `<TipKeybindLabel actionId="..." />` — it reads the i18n label and the
current combo from `$bindings`. Pass `text={...}` only when the label is
context-dependent (e.g. "Show" / "Hide"). Never hardcode combos; always use
`useKeybindHint` or `TipKeybindLabel`.
Notes:
@ -192,7 +203,12 @@ Notes:
semantics when unifying appearance.
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.
z-index literals. Pick a rung of the ladder in `styles.css` instead —
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
`--z-connecting``--z-onboarding``--z-setup``--z-crash`. Plain
`z-10`/`z-20` are still right for stacking *within* one component.
## Iconography & brand
@ -294,9 +310,10 @@ The detailed state contract lives in the scoped
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
one-off shadows?
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
- [ ] Icon-only buttons wrapped in `<Tip>` with a descriptive label?
- [ ] No native `title=` on buttons — use `<Tip>` instead?
- [ ] Keybind hints read from the store via `useKeybindHint` / `TipKeybindLabel`?
- [ ] Tips only where hover teaches something new (no kebab / menu-trigger
tips; unlabeled chrome that needs discovery gets `<Tip>` + `aria-label`)?
- [ ] No native `title=` on buttons?
- [ ] Keybind hints on tipped buttons use `useKeybindHint` / `TipKeybindLabel`?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] No automatic navigation, focus steal, or pane opening from background

View file

@ -120,6 +120,17 @@ export function createSandbox(prefix: string): Sandbox {
'utf8',
)
// Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
// ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
// click hit-testing and the committed visual baselines were calibrated at
// 100%. Without this file every sandbox would inherit the product default
// and fail pointer interception + snapshot diffs.
fs.writeFileSync(
path.join(userDataDir, 'zoom-state.json'),
JSON.stringify({ zoomLevel: 0 }, null, 2),
'utf8',
)
return {
root,
hermesHome,
@ -230,6 +241,10 @@ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
// `app.close()` in teardown must exit even when a spec leaves a turn
// mid-flight — otherwise the quit confirmation waits on a click that no
// one is there to make, and the worker dies on a teardown timeout.
HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
// Clear dev-server override — we want the built dist/, not a vite server.
// The dev-server check in main.ts looks for this env var; if it's set,
// it loads from the vite URL instead of the local file.

View file

@ -19,6 +19,12 @@ import { RealSessionBuilder } from './real-session-builder'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
// baseline count taken before that backfill sees a clipped transcript and
// falsely reports duplicates once the full list mounts. Waiting for this
// oldest row means the baseline reflects the fully-mounted transcript.
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
const HISTORY_TURNS = Array.from(
{ length: 27 },
@ -210,6 +216,16 @@ test.describe('large session resume', () => {
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
// and backfills older turns in a rAF. Wait for the oldest seeded row to
// mount before taking the baseline so it reflects the full transcript —
// otherwise a clipped baseline makes the backfilled rows look like
// duplicates of the completed reply.
await fixture.page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
OLDEST_SEEDED_TEXT,
{ timeout: 30_000 },
)
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
await fixture.mock.waitForHeldStream()

View file

@ -4,11 +4,7 @@
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
async function send(page: Page, text: string, delay = 15): Promise<void> {
@ -25,12 +21,11 @@ async function pasteAndSend(page: Page, text: string): Promise<void> {
await page.keyboard.press('Enter')
}
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
await page.waitForFunction(
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
text,
{ timeout },
{ timeout }
)
}
@ -62,23 +57,16 @@ test.describe('session compression', () => {
await send(page, 'E2E_COMPRESSION_THIRD')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
// Commit the command before typing its argument. This waits for the async
// completion request on cold CI workers, then uses the composer's own
// keyboard accept path to replace the `/compress` trigger with a command
// chip. Clicking a later completion after typing the argument can insert a
// second command token (for example `//compress ...`) as plain text.
// This test covers compression and continuation, not slash completion.
// Insert the complete command atomically and click Send so an async
// completion response cannot consume Enter as a picker acceptance.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('/compress', { delay: 15 })
await page.getByText('/compress').first().waitFor({ state: 'visible' })
await page.keyboard.press('Enter')
await composer.type(' preserve the three test turns', { delay: 15 })
await page.keyboard.press('Enter')
await page.keyboard.insertText('/compress preserve the three test turns')
await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
await page.getByRole('button', { name: 'Send', exact: true }).click()
await expect
.poll(
() => page.locator('[data-slot="aui_thread-viewport"]').textContent(),
{ timeout: 90_000 },
)
.poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
.toMatch(/Compressed|No changes from compression/)
// Compression rotates the agent's live session id. A post-compression
@ -105,7 +93,7 @@ auxiliary:
provider: custom
model: mock-model`,
mockServer: {
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
}
})
await waitForAppReady(fixture, 120_000)

View file

@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
const VALID_MARKER = {
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
schemaVersion: 1
}
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
})
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
assert.equal(hasValidBootstrapMarker(null, 1), false)
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
})
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
hasValidMarker: false,
shouldUseActiveRuntime: true,
usabilityReason: 'usable'
})
})
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
hasValidMarker: false,
shouldUseActiveRuntime: true,
usabilityReason: 'usable'
})
})
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
hasValidMarker: true,
shouldUseActiveRuntime: false,
usabilityReason: 'unusable'
})
})
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
// repo+venv, no desktop-managed marker was ever written, and every launch
// dropped the user back into the first-run installer.
const state = classifyActiveRuntime(null, 1, true)
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
})
test('a repair that deleted the marker does not strand a healthy install', () => {
// #72166: the repair handler clears the marker unconditionally. Runtime
// usability, not marker presence, must decide the next boot.
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
})

View file

@ -0,0 +1,58 @@
export interface BootstrapMarkerLike {
pinnedCommit?: unknown
schemaVersion?: unknown
}
export interface ActiveRuntimeState {
hasValidMarker: boolean
shouldUseActiveRuntime: boolean
usabilityReason: 'usable' | 'unusable'
}
export function hasValidBootstrapMarker(
marker: BootstrapMarkerLike | null | undefined,
schemaVersion: number
): boolean {
if (!marker || typeof marker !== 'object') {
return false
}
if (marker.schemaVersion !== schemaVersion) {
return false
}
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
return false
}
return true
}
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
// was installed by the CLI first, or when a past desktop build forgot the
// marker). Runtime usability is authoritative for "can we launch local Hermes
// right now?"; the marker is only provenance about how that install was
// created. A missing/stale marker must never force a healthy local install into
// the first-run bootstrap UI.
export function classifyActiveRuntime(
marker: BootstrapMarkerLike | null | undefined,
schemaVersion: number,
runtimeUsable: boolean
): ActiveRuntimeState {
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
if (!runtimeUsable) {
return {
hasValidMarker,
shouldUseActiveRuntime: false,
usabilityReason: 'unusable'
}
}
return {
hasValidMarker,
shouldUseActiveRuntime: true,
usabilityReason: 'usable'
}
}

View file

@ -36,6 +36,7 @@ import {
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveProfileBackendRoute,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,
@ -187,6 +188,65 @@ test('saved SSH drafts are inactive and explicit overrides take precedence', ()
assert.equal(profileHasRemoteConnection(config, 'coder'), true)
})
// --- resolveProfileBackendRoute ---
const ROUTES = [
{
name: 'the primary profile owns the window backend',
profile: 'default',
opts: { primaryProfile: 'default' },
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
},
{
name: 'a renamed primary profile still owns the window backend',
profile: ' coder ',
opts: { primaryProfile: 'coder', globalRemote: true },
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
},
{
name: 'an unset profile resolves to the primary',
profile: '',
opts: { primaryProfile: 'default', globalRemote: true },
expected: { backend: 'primary', descriptorProfile: null, scopePath: false }
},
{
name: 'a profile inheriting the app-global remote shares the primary backend, scoped per request',
profile: 'coder',
opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: false },
expected: { backend: 'primary', descriptorProfile: 'coder', scopePath: true }
},
{
name: 'a profile with its own remote override gets a pooled descriptor for that host',
profile: 'coder',
opts: { primaryProfile: 'default', globalRemote: true, profileRemoteOverride: true },
expected: { backend: 'pool', descriptorProfile: null, scopePath: false }
},
{
name: 'a local non-primary profile gets its own pooled backend',
profile: 'coder',
opts: { primaryProfile: 'default', globalRemote: false, profileRemoteOverride: false },
expected: { backend: 'pool', descriptorProfile: null, scopePath: false }
}
]
for (const route of ROUTES) {
test(`resolveProfileBackendRoute: ${route.name}`, () => {
assert.deepEqual(resolveProfileBackendRoute(route.profile, route.opts), route.expected)
})
}
test('resolveProfileBackendRoute only tags a descriptor when the backend is shared', () => {
// A pooled backend is already scoped to its profile, so tagging it would
// imply a second scope the caller must reconcile. Only the shared
// global-remote route carries one.
for (const route of ROUTES) {
const resolved = resolveProfileBackendRoute(route.profile, route.opts)
assert.equal(Boolean(resolved.descriptorProfile), resolved.scopePath)
assert.ok(!resolved.descriptorProfile || resolved.backend === 'primary')
}
})
// --- pathWithGlobalRemoteProfile ---
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
@ -199,6 +259,17 @@ test('pathWithGlobalRemoteProfile appends profile in global remote mode', () =>
)
})
test('pathWithGlobalRemoteProfile skips the primary profile, which the remote already serves', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/info', 'coder', {
globalRemote: true,
primaryProfile: 'coder',
profileRemoteOverride: false
}),
'/api/model/info'
)
})
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
assert.equal(
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {

View file

@ -350,16 +350,68 @@ function profileRemoteOverride(config, profile) {
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
}
export interface ProfileRouteOptions {
globalRemote?: boolean
primaryProfile?: null | string
profileRemoteOverride?: boolean
}
export interface ProfileBackendRoute {
/** Which backend serves this profile: the window backend, or a pooled one. */
backend: 'pool' | 'primary'
/**
* Profile to tag on the returned descriptor when the backend is shared and
* therefore not itself scoped to that profile. Null when the backend already
* belongs to the profile.
*/
descriptorProfile: null | string
/** Whether REST paths on this route must carry `?profile=` to be scoped. */
scopePath: boolean
}
/**
* In global-remote mode one backend serves every Desktop profile, so REST calls
* that are scoped by renderer-side `request.profile` must carry that scope as a
* query parameter. Local pooled backends and per-profile remote overrides do not
* need this: they already run against a backend scoped to the target profile.
* The one place that answers "which backend serves profile P, and does its
* REST path need a profile scope?". Four routes, in precedence order:
*
* 1. The primary profile owns the window backend outright.
* 2. A profile with its own remote override gets a pooled descriptor for that
* host, which is already scoped to it.
* 3. A profile inheriting the app-global remote shares the primary backend
* one host serves every profile so it is scoped per request instead.
* 4. Any other local profile gets its own pooled backend, spawned with
* `--profile`, so its `HERMES_HOME` scopes it.
*
* Routing used to be spread across three overlapping predicates that each
* re-derived part of this table, which is how case 3 ended up registering
* reapable pool entries for backends it never owned.
*/
function pathWithGlobalRemoteProfile(path, profile, opts: any = {}) {
function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): ProfileBackendRoute {
const scopedProfile = connectionScopeKey(profile)
const primaryProfile = connectionScopeKey(opts.primaryProfile) || 'default'
if (!scopedProfile || scopedProfile === primaryProfile) {
return { backend: 'primary', descriptorProfile: null, scopePath: false }
}
if (opts.profileRemoteOverride) {
return { backend: 'pool', descriptorProfile: null, scopePath: false }
}
if (opts.globalRemote) {
return { backend: 'primary', descriptorProfile: scopedProfile, scopePath: true }
}
return { backend: 'pool', descriptorProfile: null, scopePath: false }
}
/**
* Add renderer-side `request.profile` to a REST path when the route says the
* serving backend is not already scoped to that profile.
*/
function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = {}) {
const scopedProfile = connectionScopeKey(profile)
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
if (!resolveProfileBackendRoute(profile, opts).scopePath) {
return path
}
@ -506,6 +558,7 @@ export {
profileRemoteOverride,
profileSshOverride,
resolveAuthMode,
resolveProfileBackendRoute,
resolveTestWsUrl,
RT_COOKIE_VARIANTS,
savedProfileSsh,

View file

@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import { describeCrashReason, installCrashForensics } from './crash-forensics'
const harness = () => {
const listeners = new Map<string, (value: unknown) => void>()
const flush = vi.fn()
const log = vi.fn()
installCrashForensics({
flush,
log,
target: { on: (event, listener) => listeners.set(event, listener) }
})
return { flush, listeners, log }
}
describe('describeCrashReason', () => {
it('prefers a stack, then a message, for thrown errors', () => {
const withStack = new Error('boom')
withStack.stack = 'Error: boom\n at somewhere'
expect(describeCrashReason(withStack)).toBe('Error: boom\n at somewhere')
const withoutStack = new Error('boom')
withoutStack.stack = ''
expect(describeCrashReason(withoutStack)).toBe('boom')
})
it('renders non-error rejections without throwing', () => {
expect(describeCrashReason('plain string')).toBe('plain string')
expect(describeCrashReason({ code: 'ECONNRESET' })).toBe('{"code":"ECONNRESET"}')
expect(describeCrashReason(undefined)).toBe('undefined')
const circular: Record<string, unknown> = {}
circular.self = circular
expect(describeCrashReason(circular)).toBe('[object Object]')
})
})
describe('installCrashForensics', () => {
it('records and synchronously flushes an uncaught exception', () => {
const { flush, listeners, log } = harness()
const error = new Error('renderer gone')
error.stack = 'Error: renderer gone\n at main'
listeners.get('uncaughtException')?.(error)
expect(log).toHaveBeenCalledWith('[main] Uncaught exception: Error: renderer gone\n at main')
expect(flush).toHaveBeenCalledTimes(1)
})
it('records and synchronously flushes an unhandled rejection', () => {
const { flush, listeners, log } = harness()
listeners.get('unhandledRejection')?.('gateway ticket mint failed')
expect(log).toHaveBeenCalledWith('[main] Unhandled rejection: gateway ticket mint failed')
expect(flush).toHaveBeenCalledTimes(1)
})
it('registers both handlers', () => {
const { listeners } = harness()
expect([...listeners.keys()].sort()).toEqual(['uncaughtException', 'unhandledRejection'])
})
})

View file

@ -0,0 +1,51 @@
/**
* Last-chance forensics for the Electron main process.
*
* Electron installs its own `uncaughtException` listener and only warns on
* unhandled rejections, so the app usually survives but the reason lands on
* stderr alone, which is discarded entirely when the app is launched from
* Finder or the Start menu. Without a record in desktop.log, a main-process
* fault is invisible in a `hermes debug share` bundle and the user is left
* describing symptoms instead of showing a stack.
*/
export interface CrashForensicsTarget {
on: (event: 'uncaughtException' | 'unhandledRejection', listener: (value: unknown) => void) => unknown
}
export interface CrashForensicsOptions {
flush: () => void
log: (message: string) => void
target?: CrashForensicsTarget
}
/** Render a thrown value for the log, preferring a stack over a bare message. */
export function describeCrashReason(reason: unknown): string {
if (reason instanceof Error) {
return reason.stack || reason.message || reason.name || 'Error'
}
if (typeof reason === 'string') {
return reason
}
try {
return JSON.stringify(reason) ?? String(reason)
} catch {
return String(reason)
}
}
/**
* Record main-process faults to desktop.log and flush synchronously, since a
* fault that does prove fatal leaves no chance for the batched async flush.
*/
export function installCrashForensics({ flush, log, target = process }: CrashForensicsOptions): void {
const record = (label: string) => (reason: unknown) => {
log(`[main] ${label}: ${describeCrashReason(reason)}`)
flush()
}
target.on('uncaughtException', record('Uncaught exception'))
target.on('unhandledRejection', record('Unhandled rejection'))
}

View file

@ -0,0 +1,94 @@
/**
* Tests for electron/dev-cdp.ts.
*
* Run with: npx vitest run --project electron electron/dev-cdp.test.ts
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
const DEV_SERVER = 'http://127.0.0.1:5174'
/** The ordinary `npm run dev` / `hgui` run. */
const devRun = { env: {}, isPackaged: false, devServer: DEV_SERVER }
test('a dev-server run opens the default port with no opt-in', () => {
assert.deepEqual(resolveDevCdpPort(devRun), { port: DEFAULT_PORT, reason: null })
})
test('the default matches what the scripts/ tooling reaches for', () => {
// scripts/eval.mjs and scripts/perf/lib/cdp.mjs both default here; if this
// drifts, `node scripts/eval.mjs ...` stops finding a live renderer.
assert.equal(DEFAULT_PORT, 9222)
})
test('a packaged build never opens the port, however loudly the env asks', () => {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9222' }, isPackaged: true })
assert.deepEqual(decision, { port: null, reason: 'packaged' })
})
test('packaged is checked before every other gate', () => {
// Belt-and-suspenders: dev server present, valid port requested, still shut.
for (const value of ['9222', '', 'off', 'garbage']) {
const decision = resolveDevCdpPort({
env: { HERMES_DESKTOP_CDP_PORT: value },
isPackaged: true,
devServer: DEV_SERVER
})
assert.equal(decision.port, null, `expected packaged to refuse ${JSON.stringify(value)}`)
assert.equal(decision.reason, 'packaged')
}
})
test('an unpackaged dist run (no dev server) does not qualify', () => {
// `electron .` against dist/ is how the packaged app gets smoke tested; it
// should behave like the packaged app, not like a source-tree dev run.
assert.deepEqual(resolveDevCdpPort({ ...devRun, devServer: undefined }), { port: null, reason: 'no-dev-server' })
})
test('the port is overridable', () => {
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9333' } }).port, 9333)
})
test('tolerates surrounding whitespace on the override', () => {
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: ' 9333 ' } }).port, 9333)
})
test('can be switched off on a dev run', () => {
for (const value of ['0', 'off', 'OFF', 'false', 'no']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to close the port`)
assert.equal(decision.reason, 'opted-out')
}
})
test('refuses ports that are not usable integers', () => {
for (const value of ['80', '-1', '70000', 'yes', '9222.5', '92 22']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to be refused`)
assert.equal(decision.reason, 'invalid-port')
}
})
test('explains itself when an explicit setting was not honoured', () => {
// A typo'd port or a deliberate opt-out should say so — silently doing
// something other than what the env asked for is the bad failure mode.
for (const value of ['garbage', 'off']) {
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
assert.ok(describeDevCdpDecision(decision), `expected an explanation for ${JSON.stringify(value)}`)
}
})
test('stays quiet when the port opened, or is closed by design', () => {
assert.equal(describeDevCdpDecision(resolveDevCdpPort(devRun)), null)
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, isPackaged: true })), null)
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, devServer: undefined })), null)
})

View file

@ -0,0 +1,108 @@
/**
* Dev Chrome DevTools Protocol exposure for the desktop renderer.
*
* The renderer is a Chromium page, so `--remote-debugging-port` turns it into
* something the repo's existing CDP tooling (`scripts/eval.mjs`,
* `scripts/perf/lib/cdp.mjs`, the `diag-*` / `probe-*` family) can attach to
* and read the live DOM from. Every one of those scripts already defaults to
* 9222, so a dev-server run opens 9222 and they just work.
*
* If you are running a dev server you are already executing arbitrary local
* JS vite's module graph and every postinstall in node_modules so a
* loopback debugging port does not meaningfully widen that. `perf:serve`
* already opens one unconditionally. What must never happen is a *packaged*
* app exposing it, which is the one hard gate here.
*
* - packaged build always closed, whatever the env says.
* - no HERMES_DESKTOP_DEV_SERVER closed (an unpackaged `electron .` against
* dist/ is how the packaged app gets smoke tested; it should behave like
* the packaged app).
* - otherwise open on 9222, or HERMES_DESKTOP_CDP_PORT.
*
* `HERMES_DESKTOP_CDP_PORT=off` (or `0` / `false`) opts out for anyone who
* wants the port closed on a dev run.
*
* The port binds to loopback (Chromium's default) and the address is
* deliberately not configurable: there is no reason to expose a renderer
* debugger off-host, and offering the knob invites someone to try.
*/
/** Why the port is closed, for a one-line log the developer can act on. */
type ClosedReason = 'packaged' | 'no-dev-server' | 'opted-out' | 'invalid-port'
type DevCdpDecision = { port: number; reason: null } | { port: null; reason: ClosedReason }
type DevCdpInput = {
env: Record<string, string | undefined>
isPackaged: boolean
devServer: string | undefined
}
/** What every script under scripts/ already reaches for. */
const DEFAULT_PORT = 9222
// Below 1024 needs privileges on most platforms; 65535 is the ceiling.
const MIN_PORT = 1024
const MAX_PORT = 65535
const OPT_OUT = new Set(['0', 'off', 'false', 'no'])
/**
* Decide whether this run may expose a renderer debugging port, and on which
* port. Pure: every input is passed in, so the gate is testable without an
* Electron app or a real environment.
*/
function resolveDevCdpPort({ env, isPackaged, devServer }: DevCdpInput): DevCdpDecision {
// Packaged wins over everything. Checked first so no combination of
// environment variables can talk a shipped build into opening the port.
if (isPackaged) {
return { port: null, reason: 'packaged' }
}
// A dev server means a source-tree run (`npm run dev` / `hgui`).
if (!devServer) {
return { port: null, reason: 'no-dev-server' }
}
const requested = (env.HERMES_DESKTOP_CDP_PORT ?? '').trim()
if (!requested) {
return { port: DEFAULT_PORT, reason: null }
}
if (OPT_OUT.has(requested.toLowerCase())) {
return { port: null, reason: 'opted-out' }
}
const port = Number(requested)
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
return { port: null, reason: 'invalid-port' }
}
return { port, reason: null }
}
/** One-line explanation for a closed port, or null when it opened. */
function describeDevCdpDecision(decision: DevCdpDecision): string | null {
switch (decision.reason) {
case null:
return null
case 'invalid-port':
return `HERMES_DESKTOP_CDP_PORT is not a valid port (expected an integer ${MIN_PORT}-${MAX_PORT}, or "off"); renderer debugging is disabled.`
case 'opted-out':
return 'renderer debugging disabled by HERMES_DESKTOP_CDP_PORT.'
// Packaged and dist-run builds are closed by design — the common case, not
// worth a line of startup noise.
case 'packaged':
case 'no-dev-server':
return null
}
}
export { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort }
export type { DevCdpDecision }

View file

@ -0,0 +1,222 @@
/**
* Unit tests for the pure find-in-page helpers. The IPC handlers in
* main.ts are the only consumer the helpers below must keep the wire
* shape stable (match counter shape, defaults, no-throw-on-destroyed).
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { describe, test } from 'vitest'
import { formatFoundInPage, installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
// Minimal webContents stub. The Electron.WebContents type is huge, so we
// model just the slice the helpers touch (`isDestroyed`, `findInPage`,
// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through
// `asWC()` at call sites.
interface FakeWebContents {
calls: {
find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }>
stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'>
send: Array<{ channel: string; payload: unknown }>
}
isDestroyed: () => boolean
destroy: () => void
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void
send: (channel: string, payload: unknown) => void
on: typeof EventEmitter.prototype.on
off: typeof EventEmitter.prototype.off
emit: (event: string | symbol, ...args: unknown[]) => boolean
}
function makeFakeWebContents(): FakeWebContents {
const emitter = new EventEmitter()
const calls = {
find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>,
stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>,
send: [] as Array<{ channel: string; payload: unknown }>
}
let destroyed = false
return {
calls,
isDestroyed: () => destroyed,
destroy() {
destroyed = true
emitter.emit('destroyed')
},
findInPage(query: string, options: { forward: boolean; findNext: boolean }) {
calls.find.push({ query, options })
},
stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') {
calls.stop.push(action)
},
send(channel: string, payload: unknown) {
calls.send.push({ channel, payload })
},
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter)
}
}
function asWC(fake: FakeWebContents): Electron.WebContents {
return fake as unknown as Electron.WebContents
}
describe('formatFoundInPage', () => {
test('maps activeMatchOrdinal + matches onto the wire payload', () => {
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), {
activeMatchOrdinal: 3,
count: 12
})
})
test('coerces missing fields to zero so the renderer never sees NaN', () => {
assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 })
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), {
activeMatchOrdinal: 0,
count: 0
})
})
test('null / undefined inputs still produce a well-formed payload', () => {
assert.deepEqual(formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), {
activeMatchOrdinal: 0,
count: 0
})
assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 })
})
})
describe('performFind', () => {
test('forwards the query and options to webContents.findInPage', () => {
const wc = makeFakeWebContents()
performFind(asWC(wc), 'hello', { forward: true, findNext: false })
assert.deepEqual(wc.calls.find, [{ query: 'hello', options: { forward: true, findNext: false } }])
})
test('defaults forward to true when omitted', () => {
const wc = makeFakeWebContents()
performFind(asWC(wc), 'x', { findNext: true })
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: true } }])
})
test('defaults findNext to false when omitted', () => {
const wc = makeFakeWebContents()
performFind(asWC(wc), 'x', { forward: false })
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: false, findNext: false } }])
})
test('treats null / non-object options as "all defaults"', () => {
const wc = makeFakeWebContents()
performFind(asWC(wc), 'x', null)
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: false } }])
})
test('coerces a non-string query to string (defensive against bad renderer payloads)', () => {
const wc = makeFakeWebContents()
performFind(asWC(wc), 42 as unknown as string, null)
assert.equal(wc.calls.find[0].query, '42')
})
test('is a no-op when webContents is null', () => {
assert.doesNotThrow(() => performFind(null, 'q', null))
})
test('is a no-op when webContents is destroyed (does not throw across IPC)', () => {
const wc = makeFakeWebContents()
wc.destroy()
performFind(asWC(wc), 'q', null)
assert.equal(wc.calls.find.length, 0)
})
})
describe('stopFind', () => {
test('calls stopFindInPage with the default action (clearSelection)', () => {
const wc = makeFakeWebContents()
stopFind(asWC(wc))
assert.deepEqual(wc.calls.stop, ['clearSelection'])
})
test('honors an explicit action argument', () => {
const wc = makeFakeWebContents()
stopFind(asWC(wc), 'keepSelection')
assert.deepEqual(wc.calls.stop, ['keepSelection'])
})
test('is a no-op when webContents is null or destroyed', () => {
assert.doesNotThrow(() => stopFind(null))
const wc = makeFakeWebContents()
wc.destroy()
stopFind(asWC(wc))
assert.equal(wc.calls.stop.length, 0)
})
})
describe('installFoundInPageForwarder', () => {
test('forwards found-in-page to the sender as a formatted payload', () => {
const wc = makeFakeWebContents()
installFoundInPageForwarder(asWC(wc))
// Drive the fake's emit directly — this exercises the same code path
// as Electron's actual `webContents.emit('found-in-page', …)`.
wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 })
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } }])
})
test('handles missing fields without throwing', () => {
const wc = makeFakeWebContents()
installFoundInPageForwarder(asWC(wc))
wc.emit('found-in-page', {}, {})
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } }])
})
test('skips send when webContents is destroyed at fire time', () => {
const wc = makeFakeWebContents()
installFoundInPageForwarder(asWC(wc))
wc.destroy()
wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to')
})
test('returned uninstall removes the listener', () => {
const wc = makeFakeWebContents()
const uninstall = installFoundInPageForwarder(asWC(wc))
uninstall()
wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 })
assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire')
})
test('returned uninstall on a null webContents is a safe no-op', () => {
const uninstall = installFoundInPageForwarder(null)
assert.doesNotThrow(() => uninstall())
})
test('returned uninstall on a destroyed webContents is a safe no-op', () => {
const wc = makeFakeWebContents()
wc.destroy()
const uninstall = installFoundInPageForwarder(asWC(wc))
assert.doesNotThrow(() => uninstall())
})
// Regression: the original PR scoped the forwarder to the global mainWindow,
// so Cmd+F pressed in a secondary session window routed results back to the
// primary. Pin that the helper does NOT close over any window other than the
// webContents it was given — two forwarders installed on two distinct fakes
// must each send only to their own sender.
test('two forwarders installed on distinct webContents do not cross-fire', () => {
const wcA = makeFakeWebContents()
const wcB = makeFakeWebContents()
installFoundInPageForwarder(asWC(wcA))
installFoundInPageForwarder(asWC(wcB))
wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
assert.deepEqual(wcA.calls.send, [
{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } }
])
assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results')
})
})

View file

@ -0,0 +1,120 @@
/**
* Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F).
*
* The renderer drives an Electron `webContents.findInPage` over IPC so it can
* reuse the native "find-in-page" experience (incremental search, match
* highlight, Enter to step, Shift+Enter to step backwards, Escape to clear)
* across chat transcripts and editor panels. Everything in this module is
* pure with respect to its inputs so the routing + payload shaping can be
* unit-tested without booting a BrowserWindow.
*
* Multi-window correctness: the IPC handlers in main.ts resolve the
* requesting window via `BrowserWindow.fromWebContents(event.sender)` so a
* Cmd+F pressed in a secondary session window searches THAT window, not the
* primary. The `found-in-page` results are forwarded back to the same sender
* see {@link installFoundInPageForwarder}.
*/
/** Match options accepted by the renderer's `findInPage` bridge call. */
export interface FindInPageOptions {
/** Step direction. Defaults to `true` (forward). */
forward?: boolean
/**
* `true` to advance to the next/previous match using the previous query;
* `false` to (re)search the current `query` from scratch. The renderer
* passes `false` on a fresh query and `true` on Enter / Shift+Enter.
*/
findNext?: boolean
}
/** Payload shape sent back to the renderer on every `found-in-page` event. */
export interface FoundInPagePayload {
/** 1-indexed ordinal of the active match, or 0 when none. */
activeMatchOrdinal: number
/** Total matches in the document for the current query. */
count: number
}
/**
* Defensive projection of Electron's `found-in-page` event result. Electron
* exposes more fields (finalUpdate, selectionArea, etc.) that we don't need;
* keeping the projection explicit makes the wire shape auditable and keeps
* tests independent of the runtime type.
*/
export function formatFoundInPage(result: { activeMatchOrdinal?: number; matches?: number }): FoundInPagePayload {
return {
activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0),
count: Number(result?.matches ?? 0)
}
}
/**
* Issue a `findInPage` against the given `webContents`. No-op when the
* webContents is missing or destroyed surfaces as a silent miss rather
* than throwing across the IPC boundary, matching Electron's own semantics
* for a destroyed renderer.
*/
export function performFind(
webContents: Electron.WebContents | null | undefined,
query: string,
options: FindInPageOptions | null | undefined
): void {
if (!webContents || webContents.isDestroyed()) {
return
}
const opts = options && typeof options === 'object' ? options : {}
webContents.findInPage(String(query ?? ''), {
forward: opts.forward !== false,
findNext: Boolean(opts.findNext)
})
}
/**
* Stop the current find and clear highlights. The default `action` matches
* what the renderer sends on Escape / close.
*/
export function stopFind(
webContents: Electron.WebContents | null | undefined,
action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection'
): void {
if (!webContents || webContents.isDestroyed()) {
return
}
webContents.stopFindInPage(action)
}
/**
* Install a `found-in-page` listener on the given sender `webContents` and
* forward each result back to the SAME renderer (via `webContents.send`).
*
* Returns an uninstall function. Call it from `webContents.on('destroyed', …)`
* to avoid leaking the listener when the window goes away Electron does
* not auto-detach webContents listeners on close.
*
* The forwarder is intentionally bound to a single sender rather than the
* primary window: a Cmd+F pressed in a secondary session window must
* highlight matches in THAT window, and the match counter must reflect
* THAT window's DOM, not the primary's.
*/
export function installFoundInPageForwarder(webContents: Electron.WebContents | null | undefined): () => void {
if (!webContents || webContents.isDestroyed()) {
return () => {}
}
const handler = (_event: Electron.Event, result: Parameters<typeof formatFoundInPage>[0]) => {
if (webContents.isDestroyed()) {
return
}
webContents.send('hermes:found-in-page', formatFoundInPage(result))
}
webContents.on('found-in-page', handler)
return () => {
webContents.off('found-in-page', handler)
}
}

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,41 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
// shortcut + the persisted preference; the quick window only captures text
// and hands it back, and the primary renderer submits it through the normal
// prompt path.
quickEntry: {
getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'),
setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch),
submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload),
dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'),
// Primary renderer → main → quick window: gateway connection state + the
// recent-session options the target picker offers. Main caches the latest
// payload so a freshly spawned quick window starts from truth.
pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload),
// Quick window subscribes to those pushes.
onState: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:state', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener)
},
// Main → primary renderer: a submit captured by the quick window.
onSubmit: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:quick-entry:submit', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener)
},
// Main → quick window: you were just summoned (reset draft + refocus).
onShown: callback => {
const listener = () => callback()
ipcRenderer.on('hermes:quick-entry:shown', listener)
return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener)
}
},
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
@ -79,6 +114,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload),
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
@ -268,5 +304,19 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
themes: {
fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id),
searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query)
},
// Find-in-page (Ctrl/Cmd+F): delegates to Electron's
// webContents.findInPage on the IPC sender's window so a Cmd+F pressed
// in a secondary session window searches THAT window, not the primary.
// `onFoundInPage` returns the unsubscribe fn; the renderer wires it via
// `initFindInPageListener` in store/find-in-page.ts and tears it down
// when the FindBar unmounts.
findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options),
stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'),
onFoundInPage: callback => {
const listener = (_event, result) => callback(result)
ipcRenderer.on('hermes:found-in-page', listener)
return () => ipcRenderer.removeListener('hermes:found-in-page', listener)
}
})

View file

@ -0,0 +1,30 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { fetchPrimaryProfileSessions } from './profile-session-routing'
test('primary session reads use the profile-aware request path', async () => {
const calls: Array<{ profile: string | null; path: string }> = []
const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } }
const result = await fetchPrimaryProfileSessions(
new URLSearchParams({ profile: 'default', limit: '20' }),
async (profile, path) => {
calls.push({ profile, path })
return expected
}
)
assert.deepEqual(calls, [{ profile: null, path: '/api/profiles/sessions?profile=default&limit=20' }])
assert.equal(result, expected)
})
test('primary session reads preserve the empty-list fallback', async () => {
const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'all' }), async () => {
throw new Error('remote unavailable')
})
assert.deepEqual(result, { sessions: [], total: 0, profile_totals: {} })
})

View file

@ -0,0 +1,19 @@
export interface ProfileSessionsResponse {
sessions: unknown[]
total: number
profile_totals: Record<string, number>
[key: string]: unknown
}
type FetchJsonForProfile = (profile: string | null, path: string) => Promise<unknown>
export async function fetchPrimaryProfileSessions(
searchParams: URLSearchParams,
fetchJsonForProfile: FetchJsonForProfile
): Promise<ProfileSessionsResponse> {
try {
return (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`)) as ProfileSessionsResponse
} catch {
return { sessions: [], total: 0, profile_totals: {} }
}
}

View file

@ -0,0 +1,240 @@
import { describe, expect, it, vi } from 'vitest'
import {
createQuickEntryShortcut,
DEFAULT_QUICK_ENTRY_SHORTCUT,
type GlobalShortcutLike,
parseQuickEntryShortcut,
quickEntryWindowBounds,
sanitizeQuickEntrySettings
} from './quick-entry'
function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) {
const held = new Set(options.taken ?? [])
const globalShortcut: GlobalShortcutLike = {
isRegistered: vi.fn((accelerator: string) => held.has(accelerator)),
register: vi.fn((accelerator: string) => {
if (options.register === false) {
return false
}
held.add(accelerator)
return true
}),
unregister: vi.fn((accelerator: string) => void held.delete(accelerator))
}
return { globalShortcut, held }
}
describe('parseQuickEntryShortcut', () => {
it('normalizes casing, aliases, and modifier order', () => {
expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({
accelerator: 'CommandOrControl+Shift+Space',
ok: true
})
expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true })
expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true })
expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true })
})
it('collapses duplicate modifiers', () => {
expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true })
})
it('requires a modifier so a global bind cannot swallow a bare key', () => {
expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' })
expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' })
})
it('requires exactly one non-modifier key', () => {
expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' })
expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' })
expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' })
})
it('rejects empty, junk, and the reserved Escape key', () => {
expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' })
expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' })
// Escape hides the window; binding it globally would make it un-toggleable.
expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' })
})
it('accepts the shipped default unchanged', () => {
expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({
accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT,
ok: true
})
})
})
describe('sanitizeQuickEntrySettings', () => {
it('defaults to enabled with the default shortcut', () => {
expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
expect(sanitizeQuickEntrySettings('not an object')).toEqual({
enabled: true,
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
})
})
it('keeps an explicit disable and normalizes a stored shortcut', () => {
expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({
enabled: false,
shortcut: 'Alt+J'
})
})
it('falls back to the default when the stored shortcut is unusable', () => {
expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({
enabled: true,
shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT
})
})
it('treats a non-boolean enabled as off (only `true` opts in once present)', () => {
expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false)
})
})
describe('createQuickEntryShortcut', () => {
it('registers the normalized accelerator when enabled', () => {
const { globalShortcut } = fakeGlobalShortcut()
const onTrigger = vi.fn()
const controller = createQuickEntryShortcut(globalShortcut, onTrigger)
const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' })
expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' })
expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger)
expect(controller.current()).toEqual(state)
})
it('never registers while the setting is disabled', () => {
const { globalShortcut } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
expect(globalShortcut.register).not.toHaveBeenCalled()
expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT })
})
it('releases the old accelerator before registering a new one', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
controller.apply({ enabled: true, shortcut: 'Alt+K' })
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
expect(held.has('Alt+J')).toBe(false)
expect(held.has('Alt+K')).toBe(true)
})
it('turning the feature off releases the live accelerator', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
const off = controller.apply({ enabled: false, shortcut: 'Alt+J' })
expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J')
expect(held.size).toBe(0)
expect(off.registered).toBe(false)
expect(off.error).toBeNull()
})
it("surfaces 'taken' when another app already owns the chord", () => {
const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] })
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
const state = controller.apply({ enabled: true, shortcut: 'alt+j' })
expect(globalShortcut.register).not.toHaveBeenCalled()
expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' })
})
it("surfaces 'taken' when the OS refuses the registration", () => {
const { globalShortcut } = fakeGlobalShortcut({ register: false })
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({
error: 'taken',
registered: false,
shortcut: 'Alt+J'
})
})
it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => {
const { globalShortcut } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({
error: 'invalid',
registered: false,
shortcut: 'J'
})
expect(globalShortcut.register).not.toHaveBeenCalled()
})
it('survives a throwing globalShortcut', () => {
const globalShortcut: GlobalShortcutLike = {
isRegistered: () => false,
register: () => {
throw new Error('x11 grab failed')
},
unregister: () => {}
}
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken')
})
it('dispose releases the accelerator and is idempotent', () => {
const { globalShortcut, held } = fakeGlobalShortcut()
const controller = createQuickEntryShortcut(globalShortcut, vi.fn())
controller.apply({ enabled: true, shortcut: 'Alt+J' })
controller.dispose()
controller.dispose()
expect(globalShortcut.unregister).toHaveBeenCalledTimes(1)
expect(held.size).toBe(0)
expect(controller.current().registered).toBe(false)
})
})
describe('quickEntryWindowBounds', () => {
it('centers horizontally and sits below the top edge of the work area', () => {
const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 })
expect(bounds.width).toBe(640)
expect(bounds.x).toBe((1600 - 640) / 2)
expect(bounds.y).toBeGreaterThan(0)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000)
})
it('respects a display origin offset (second monitor)', () => {
const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 })
expect(bounds.x).toBe(1600 + (1440 - 640) / 2)
expect(bounds.y).toBeGreaterThanOrEqual(-200)
})
it('stays inside a tiny work area', () => {
const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 })
expect(bounds.width).toBeLessThanOrEqual(320)
expect(bounds.height).toBeLessThanOrEqual(120)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(120)
})
it('falls back to the origin without a work area', () => {
expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 })
})
})

View file

@ -0,0 +1,426 @@
/**
* Quick Entry the global-hotkey mini composer.
*
* A small frameless always-on-top window that a global shortcut summons from
* anywhere so the user can fire a prompt at Hermes without raising the whole
* app. The window carries NO gateway connection of its own: it forwards the
* text to the primary renderer, which sends it through the SAME prompt-submit
* path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge).
*
* Everything Electron-free lives here so the parts that actually break a user
* accelerator validation, "disabled means never register", and surfacing a
* shortcut another app already owns are unit-testable without booting
* Electron. main.ts owns the BrowserWindow, the file I/O, and the real
* `globalShortcut`.
*/
// Default matches the muscle memory of the apps this ports from (Claude
// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord).
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space'
// Compact capture surface: wide enough for a sentence, short enough to read as
// a HUD rather than a second app window. Height covers the composer row plus
// the session-target picker row; the renderer never grows the OS window in v1.
const QUICK_ENTRY_WINDOW_WIDTH = 640
const QUICK_ENTRY_WINDOW_HEIGHT = 168
// Spotlight-ish placement: horizontally centered on the active display, a
// comfortable fraction down from the top rather than dead center.
const QUICK_ENTRY_TOP_FRACTION = 0.22
// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator).
// Kept as data so validation and the settings UI agree on one list.
const ACCELERATOR_MODIFIERS = new Set([
'alt',
'altgr',
'cmd',
'cmdorctrl',
'command',
'commandorcontrol',
'control',
'ctrl',
'meta',
'option',
'shift',
'super'
])
const ACCELERATOR_KEYS = new Set([
'backspace',
'delete',
'down',
'end',
'enter',
'escape',
'home',
'insert',
'left',
'medianexttrack',
'mediaplaypause',
'mediaprevioustrack',
'mediastop',
'pagedown',
'pageup',
'plus',
'printscreen',
'return',
'right',
'space',
'tab',
'up',
'volumedown',
'volumemute',
'volumeup'
])
// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below.
const ACCELERATOR_PUNCTUATION = new Set([
'!',
'"',
'#',
'$',
'%',
'&',
"'",
'(',
')',
'*',
'+',
',',
'-',
'.',
'/',
':',
';',
'<',
'=',
'>',
'?',
'@',
'[',
'\\',
']',
'^',
'_',
'`',
'{',
'|',
'}',
'~'
])
/** Why a shortcut string was rejected. The renderer maps these to copy. */
export type QuickEntryShortcutError =
| 'empty'
| 'invalid-key'
| 'invalid-modifier'
| 'no-key'
| 'no-modifier'
| 'reserved'
export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true }
function isAcceleratorKey(token: string): boolean {
if (ACCELERATOR_KEYS.has(token)) {
return true
}
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) {
return true
}
if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) {
return true
}
return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token))
}
/**
* Validate + normalize a user-typed accelerator.
*
* Rules beyond Electron's own grammar, both deliberate:
* - At least one modifier. A bare global key steals that key from EVERY app.
* - `Escape` can't be the key: inside the window Escape means "hide", so
* binding it globally would make the shortcut un-toggleable.
*/
export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse {
if (typeof raw !== 'string' || !raw.trim()) {
return { ok: false, reason: 'empty' }
}
const parts = raw
.split('+')
.map(part => part.trim())
.filter(Boolean)
if (parts.length === 0) {
return { ok: false, reason: 'empty' }
}
const modifiers: string[] = []
let key: null | string = null
for (const part of parts) {
const lower = part.toLowerCase()
if (ACCELERATOR_MODIFIERS.has(lower)) {
if (key) {
// A modifier after the key ("A+Shift") is not a valid accelerator.
return { ok: false, reason: 'invalid-modifier' }
}
modifiers.push(lower)
continue
}
if (key) {
// Two non-modifier keys ("Shift+A+B").
return { ok: false, reason: 'invalid-key' }
}
if (!isAcceleratorKey(lower)) {
return { ok: false, reason: 'invalid-key' }
}
key = lower
}
if (!key) {
return { ok: false, reason: 'no-key' }
}
if (modifiers.length === 0) {
return { ok: false, reason: 'no-modifier' }
}
if (key === 'escape') {
return { ok: false, reason: 'reserved' }
}
// Canonical casing so a saved shortcut round-trips identically no matter how
// the user typed it, and duplicate modifiers collapse.
const seen = new Set<string>()
const normalizedModifiers = modifiers
.map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier)
.filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true)))
// Stable display order (Electron itself is order-insensitive).
.sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right))
return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true }
}
const CANONICAL_MODIFIER: Record<string, string> = {
alt: 'Alt',
altgr: 'AltGr',
cmd: 'Command',
cmdorctrl: 'CommandOrControl',
command: 'Command',
commandorcontrol: 'CommandOrControl',
control: 'Control',
ctrl: 'Control',
meta: 'Super',
option: 'Option',
shift: 'Shift',
super: 'Super'
}
const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift']
const CANONICAL_KEY: Record<string, string> = {
backspace: 'Backspace',
delete: 'Delete',
down: 'Down',
end: 'End',
enter: 'Enter',
escape: 'Escape',
home: 'Home',
insert: 'Insert',
medianexttrack: 'MediaNextTrack',
mediaplaypause: 'MediaPlayPause',
mediaprevioustrack: 'MediaPreviousTrack',
mediastop: 'MediaStop',
pagedown: 'PageDown',
pageup: 'PageUp',
plus: 'Plus',
printscreen: 'PrintScreen',
return: 'Return',
right: 'Right',
space: 'Space',
tab: 'Tab',
up: 'Up',
volumedown: 'VolumeDown',
volumemute: 'VolumeMute',
volumeup: 'VolumeUp',
left: 'Left'
}
function canonicalKey(key: string): string {
if (CANONICAL_KEY[key]) {
return CANONICAL_KEY[key]
}
if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) {
return key.toUpperCase()
}
if (key.length === 1 && /^[a-z]$/.test(key)) {
return key.toUpperCase()
}
return key
}
/** The persisted shape of `quick-entry.json` (main-process owned). */
export interface QuickEntrySettings {
enabled: boolean
shortcut: string
}
/**
* Raw persisted JSON usable settings. A malformed/absent file, or a shortcut
* that no longer validates (hand-edited, or from a future build), falls back to
* the default shortcut rather than leaving the feature un-summonable.
*/
export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings {
const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
const parsed = parseQuickEntryShortcut(record.shortcut)
return {
// Default ON: the feature is inert until the shortcut is pressed.
enabled: record.enabled === undefined ? true : record.enabled === true,
shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT
}
}
/** The slice of Electron's `globalShortcut` we use (injected for testing). */
export interface GlobalShortcutLike {
isRegistered(accelerator: string): boolean
register(accelerator: string, callback: () => void): boolean
unregister(accelerator: string): void
}
/**
* What Settings shows. `registered` is the ground truth (we asked the OS);
* `error` distinguishes "you turned it off" from "another app owns that chord",
* which is the failure this feature must never swallow.
*/
export interface QuickEntryRegistration {
error: null | QuickEntryRegistrationError
registered: boolean
shortcut: string
}
export type QuickEntryRegistrationError = 'invalid' | 'taken'
export interface QuickEntryShortcutController {
/** Registration state as of the last apply. */
current(): QuickEntryRegistration
/** Release the shortcut (quit / feature off). Idempotent. */
dispose(): void
/** Re-register to match `settings`. Returns the resulting state. */
apply(settings: QuickEntrySettings): QuickEntryRegistration
}
/**
* Owns the one live global accelerator. Single resolver so every caller boot,
* the settings write, quit gets the same answer and we can never leak two
* registrations for one feature.
*
* Disabled settings never touch `register()` at all: a user who turned Quick
* Entry off must not have their chord silently held hostage.
*/
export function createQuickEntryShortcut(
globalShortcut: GlobalShortcutLike,
onTrigger: () => void
): QuickEntryShortcutController {
let active: null | string = null
let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }
const release = () => {
if (active) {
try {
globalShortcut.unregister(active)
} catch {
// Best effort — a dead accelerator must not block a re-register.
}
active = null
}
}
return {
apply(settings) {
const parsed = parseQuickEntryShortcut(settings.shortcut)
const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut
release()
if (!settings.enabled) {
state = { error: null, registered: false, shortcut }
return state
}
if (!parsed.ok) {
state = { error: 'invalid', registered: false, shortcut }
return state
}
// `isRegistered` catches the common conflict before we ask, and
// `register()` returning false catches the rest (another process owns it
// OS-wide). Both land in the same surfaced 'taken' state.
let ok = false
try {
ok = globalShortcut.isRegistered(parsed.accelerator)
? false
: globalShortcut.register(parsed.accelerator, onTrigger)
} catch {
ok = false
}
active = ok ? parsed.accelerator : null
state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator }
return state
},
current() {
return state
},
dispose() {
release()
state = { ...state, error: null, registered: false }
}
}
}
/**
* Where the quick window opens on a given display work area. Centered
* horizontally, a fraction down from the top, and clamped so it stays fully
* inside the work area on small/odd displays.
*/
export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): {
height: number
width: number
x: number
y: number
} {
const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH)
const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT)
if (!workArea) {
return { height, width, x: 0, y: 0 }
}
const x = Math.round(workArea.x + (workArea.width - width) / 2)
const maxY = workArea.y + workArea.height - height
const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY))
return { height, width, x, y }
}
export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH }

View file

@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { mergeActiveWork, normalizeActiveWork, quitPromptFor } from './quit-guard'
test('normalizeActiveWork drops junk and keeps the count at least the title count', () => {
assert.deepEqual(normalizeActiveWork(null), { count: 0, titles: [] })
assert.deepEqual(normalizeActiveWork({ count: 'many', titles: 'nope' }), { count: 0, titles: [] })
assert.deepEqual(normalizeActiveWork({ count: -3, titles: [' Fix login ', '', 7] }), {
count: 1,
titles: ['Fix login']
})
})
test('normalizeActiveWork keeps untitled sessions in the count', () => {
assert.deepEqual(normalizeActiveWork({ count: 3, titles: ['Fix login'] }), { count: 3, titles: ['Fix login'] })
})
test('mergeActiveWork de-dupes a session two windows both report', () => {
const merged = mergeActiveWork([
{ count: 2, titles: ['Fix login', 'Ship docs'] },
{ count: 1, titles: ['Fix login'] }
])
assert.deepEqual(merged, { count: 2, titles: ['Fix login', 'Ship docs'] })
})
test('quitPromptFor stays out of the way when nothing is running', () => {
assert.equal(quitPromptFor({ count: 0, titles: [] }, false), null)
})
test('quitPromptFor stays out of the way during an update handoff', () => {
assert.equal(quitPromptFor({ count: 2, titles: ['Fix login'] }, true), null)
})
test('quitPromptFor names the running chats', () => {
const prompt = quitPromptFor({ count: 2, titles: ['Fix login', 'Ship docs'] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 2 chats.')
assert.ok(prompt.detail.includes('• Fix login'))
assert.ok(prompt.detail.includes('• Ship docs'))
})
test('quitPromptFor summarizes past the list cap and counts untitled work', () => {
const prompt = quitPromptFor({ count: 9, titles: ['a', 'b', 'c', 'd', 'e', 'f'] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 9 chats.')
assert.ok(prompt.detail.includes('• d'))
assert.ok(!prompt.detail.includes('• e'))
assert.ok(prompt.detail.includes('• 5 more'))
})
test('quitPromptFor speaks singular for one chat', () => {
const prompt = quitPromptFor({ count: 1, titles: [] }, false)
assert.ok(prompt)
assert.equal(prompt.message, 'Hermes is still working on 1 chat.')
assert.ok(prompt.detail.includes('mid-turn'))
})

View file

@ -0,0 +1,92 @@
// Quitting with a turn in flight kills the backend mid-tool-call: the work is
// lost, and anything the agent had half-written to disk stays half-written.
// Renderers publish what they're running; the main process asks before it lets
// that go. The decision + copy live here (pure, testable) so main.ts only owns
// the IPC and the dialog call.
const MAX_LISTED = 4
export interface ActiveWork {
/** Titles of sessions running a turn. Untitled sessions contribute a count only. */
titles: string[]
/** Running turns, including untitled ones — always >= titles.length. */
count: number
}
export const NO_ACTIVE_WORK: ActiveWork = { count: 0, titles: [] }
/** Coerce an IPC payload from an untrusted renderer into an ActiveWork. */
export function normalizeActiveWork(payload: unknown): ActiveWork {
if (!payload || typeof payload !== 'object') {
return NO_ACTIVE_WORK
}
const raw = payload as { count?: unknown; titles?: unknown }
const titles = Array.isArray(raw.titles)
? raw.titles
.filter((title): title is string => typeof title === 'string')
.map(title => title.trim())
.filter(Boolean)
: []
const count = typeof raw.count === 'number' && Number.isFinite(raw.count) ? Math.max(0, Math.floor(raw.count)) : 0
return { count: Math.max(count, titles.length), titles }
}
/** Merge every window's report into one. Windows can show the same session. */
export function mergeActiveWork(reports: Iterable<ActiveWork>): ActiveWork {
const titles: string[] = []
let count = 0
for (const report of reports) {
count = Math.max(count, report.count)
for (const title of report.titles) {
if (!titles.includes(title)) {
titles.push(title)
}
}
}
return { count: Math.max(count, titles.length), titles }
}
export interface QuitPrompt {
detail: string
message: string
}
/**
* The confirmation to show, or null when quitting should just proceed.
*
* `quittingForHandoff` covers the update / swap / uninstall relaunches: those
* are the app replacing itself, not the user walking away, and a modal there
* would strand the detached script waiting on a PID that never exits.
*/
export function quitPromptFor(work: ActiveWork, quittingForHandoff: boolean): null | QuitPrompt {
if (quittingForHandoff || work.count < 1) {
return null
}
const listed = work.titles.slice(0, MAX_LISTED)
const remaining = work.count - listed.length
const lines = listed.map(title => `${title}`)
if (remaining > 0) {
lines.push(remaining === 1 ? '• 1 more' : `${remaining} more`)
}
return {
detail: [
lines.join('\n'),
lines.length > 0 ? '' : null,
'Quitting stops the agent mid-turn. Any work it has not finished writing is lost.'
]
.filter(line => line !== null)
.join('\n')
.trim(),
message: work.count === 1 ? 'Hermes is still working on 1 chat.' : `Hermes is still working on ${work.count} chats.`
}
}

View file

@ -6,6 +6,7 @@ import {
REMOTE_LIVENESS_TIMEOUT_MS,
RemoteLivenessTracker,
RemoteRevalidationCoordinator,
revalidatePooledRemoteBackends,
revalidateRemoteConnection
} from './remote-liveness'
@ -251,3 +252,102 @@ describe('revalidateRemoteConnection', () => {
expect(rejected.probe).not.toHaveBeenCalled()
})
})
describe('revalidatePooledRemoteBackends', () => {
const harness = (entries: Array<[string, { process?: unknown; remoteBaseUrl?: null | string }]>) => {
const unreachable = new Set<string>()
const log = vi.fn()
const stopBackend = vi.fn()
const probe = vi.fn(async (url: string) => {
if ([...unreachable].some(base => url.startsWith(base))) {
throw new Error('unreachable')
}
return {}
})
return {
log,
probe,
stopBackend,
unreachable,
run: (tracker: RemoteLivenessTracker) =>
revalidatePooledRemoteBackends({ entries, log, probe, stopBackend, tracker })
}
}
it('probes only pooled entries backed by a remote host', async () => {
const local = { process: {}, remoteBaseUrl: null }
const spawning = { process: null, remoteBaseUrl: null }
const remote = { process: null, remoteBaseUrl: 'https://remote.example.com' }
const pool = harness([
['local', local],
['spawning', spawning],
['remote', remote]
])
await pool.run(new RemoteLivenessTracker())
expect(pool.probe).toHaveBeenCalledTimes(1)
expect(pool.probe).toHaveBeenCalledWith('https://remote.example.com/api/status', {
timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS
})
expect(pool.stopBackend).not.toHaveBeenCalled()
})
it('drops a descriptor only after the shared failure limit', async () => {
const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com/' }]])
pool.unreachable.add('https://remote.example.com')
const tracker = new RemoteLivenessTracker()
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] })
expect(pool.stopBackend).not.toHaveBeenCalled()
}
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
expect(pool.stopBackend).toHaveBeenCalledWith('coder')
})
it('clears the streak when the host answers again', async () => {
const pool = harness([['coder', { process: null, remoteBaseUrl: 'https://remote.example.com' }]])
const tracker = new RemoteLivenessTracker()
pool.unreachable.add('https://remote.example.com')
await pool.run(tracker)
pool.unreachable.clear()
await pool.run(tracker)
pool.unreachable.add('https://remote.example.com')
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
await expect(pool.run(tracker)).resolves.toEqual({ dropped: [] })
}
expect(pool.stopBackend).not.toHaveBeenCalled()
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
})
it('keeps a healthy sibling when another profile on a different host dies', async () => {
const pool = harness([
['coder', { process: null, remoteBaseUrl: 'https://dead.example.com' }],
['writer', { process: null, remoteBaseUrl: 'https://live.example.com' }]
])
pool.unreachable.add('https://dead.example.com')
const tracker = new RemoteLivenessTracker()
for (let attempt = 1; attempt < REMOTE_LIVENESS_FAILURE_LIMIT; attempt += 1) {
await pool.run(tracker)
}
await expect(pool.run(tracker)).resolves.toEqual({ dropped: ['coder'] })
expect(pool.stopBackend).toHaveBeenCalledTimes(1)
expect(pool.stopBackend).toHaveBeenCalledWith('coder')
})
})

View file

@ -117,6 +117,68 @@ export class RemoteLivenessTracker {
}
}
export interface PooledRemoteEntry {
process?: unknown
remoteBaseUrl?: null | string
}
export interface RevalidatePooledRemoteBackendsOptions {
entries: Iterable<[string, PooledRemoteEntry]>
log: (message: string) => void
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
stopBackend: (profile: string) => void
tracker: RemoteLivenessTracker
}
/**
* Probe pooled REMOTE descriptors and drop the dead ones.
*
* A pooled entry backed by a remote host has no child process, so the 'exit'
* handler that clears a dead local backend never fires, and the renderer's
* keepalive touch keeps the idle reaper off it. Without this the pool serves a
* descriptor for an unreachable host indefinitely.
*
* Entries share the primary's failure policy, keyed per base URL, so a profile
* pointing at the same host as another does not burn the streak twice as fast.
*/
export async function revalidatePooledRemoteBackends({
entries,
log,
probe,
stopBackend,
tracker
}: RevalidatePooledRemoteBackendsOptions): Promise<{ dropped: string[] }> {
const remotes = [...entries].filter(([, entry]) => !entry.process && entry.remoteBaseUrl)
const dropped: string[] = []
await Promise.all(
remotes.map(async ([profile, entry]) => {
const baseUrl = String(entry.remoteBaseUrl).replace(/\/+$/, '')
try {
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
tracker.recordSuccess(baseUrl)
} catch {
const failure = tracker.recordFailure(baseUrl)
if (!failure.shouldReset) {
log(
`Pooled remote backend for profile "${profile}" failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping descriptor for retry.`
)
return
}
log(`Pooled remote backend for profile "${profile}" failed liveness probe; dropping stale descriptor.`)
stopBackend(profile)
dropped.push(profile)
}
})
)
return { dropped }
}
/**
* Probe the cached primary remote connection and apply the failure policy.
* The caller owns single-flight coordination; identity checks here ensure an

View file

@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
// answer stalled until the window regained focus (Chromium clamps the
// transcript flush timer for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)

View file

@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// screen through a bounded timer flush, which Chromium clamps for blurred/
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath: string) {

View file

@ -11,9 +11,11 @@ import { test, vi } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
DEFAULT_ZOOM_LEVEL,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_RESIZE_REASSERT_DELAY_MS,
ZOOM_STEP,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomReassertWindowEvents,
@ -24,26 +26,32 @@ test('storage key stays stable so persisted zoom survives upgrades', () => {
assert.equal(ZOOM_STORAGE_KEY, 'hermes:desktop:zoomLevel')
})
test('default zoom matches the Appearance 90% preset', () => {
assert.equal(ZOOM_STEP, 0.1)
assert.equal(zoomLevelToPercent(DEFAULT_ZOOM_LEVEL), 90)
assert.equal(DEFAULT_ZOOM_LEVEL, percentToZoomLevel(90))
})
test('clampZoomLevel rejects garbage and enforces bounds', () => {
assert.equal(clampZoomLevel(NaN), 0)
assert.equal(clampZoomLevel(Infinity), 0)
assert.equal(clampZoomLevel(undefined), 0)
assert.equal(clampZoomLevel('2'), 0)
assert.equal(clampZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(Infinity), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel('2'), DEFAULT_ZOOM_LEVEL)
assert.equal(clampZoomLevel(0.3), 0.3)
assert.equal(clampZoomLevel(-42), -9)
assert.equal(clampZoomLevel(42), 9)
})
test('level 0 is exactly 100 percent', () => {
test('level 0 is exactly 100 percent (Chromium actual-size baseline)', () => {
assert.equal(zoomLevelToPercent(0), 100)
assert.equal(percentToZoomLevel(100), 0)
})
test('percentToZoomLevel rejects garbage', () => {
assert.equal(percentToZoomLevel(NaN), 0)
assert.equal(percentToZoomLevel(0), 0)
assert.equal(percentToZoomLevel(-50), 0)
assert.equal(percentToZoomLevel(undefined), 0)
test('percentToZoomLevel rejects garbage by falling back to the shipped default', () => {
assert.equal(percentToZoomLevel(NaN), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(0), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(-50), DEFAULT_ZOOM_LEVEL)
assert.equal(percentToZoomLevel(undefined), DEFAULT_ZOOM_LEVEL)
})
test('preset percentages roundtrip within rounding', () => {

View file

@ -2,8 +2,11 @@
* Pure helpers for window zoom. The main process owns webContents.setZoomLevel,
* so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel
* through this one clamped scale. Percent is the user-facing unit (100 = the
* default size); Chromium's internal unit is the zoom level, where
* factor = 1.2 ^ level.
* Chromium actual-size baseline); Chromium's internal unit is the zoom level,
* where factor = 1.2 ^ level.
*
* Our shipped default is the Appearance 90% preset tight enough to feel
* denser than Chromium 100%, and selected in the UI Scale control on first run.
*/
export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
@ -12,9 +15,15 @@ const ZOOM_FACTOR_BASE = 1.2
const MIN_ZOOM_LEVEL = -9
const MAX_ZOOM_LEVEL = 9
/** Half Chromium's default step; matching the shortcuts and View menu. */
export const ZOOM_STEP = 0.1
/** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */
export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE)
export function clampZoomLevel(value) {
if (!Number.isFinite(value)) {
return 0
return DEFAULT_ZOOM_LEVEL
}
return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
@ -26,7 +35,7 @@ export function zoomLevelToPercent(level) {
export function percentToZoomLevel(percent) {
if (!Number.isFinite(percent) || percent <= 0) {
return 0
return DEFAULT_ZOOM_LEVEL
}
return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE))
@ -90,15 +99,16 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
/**
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
* global UI zoom; the pet overlay opts out because it sizes its own OS window
* to the sprite and inheriting zoom would crop it.
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
* they size their own OS window and inheriting zoom would crop/overflow them.
*
* Extracted so the "pet opts out, everything else opts in" contract is
* Extracted so the "helper windows opt out, everything else opts in" contract is
* unit-testable without booting a BrowserWindow or reading source.
*/
export const ZOOM_WINDOW_CONFIG = {
chat: { zoom: true },
petOverlay: { zoom: false }
petOverlay: { zoom: false },
quickEntry: { zoom: false }
} as const
export function zoomWiringForWindowKind(kind) {

View file

@ -106,7 +106,6 @@
"hast-util-to-text": "^4.0.2",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanostores": "^1.3.0",
@ -147,6 +146,7 @@
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@vitejs/plugin-react": "^6.0.1",
"bippy": "0.5.43",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "40.10.2",

View file

@ -0,0 +1,25 @@
// Is the tree-split preview path actually active in the running renderer?
// Checks the served source (what vite compiled) rather than guessing.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(async () => {
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
const src = await res.text()
return JSON.stringify({
previewShift: src.includes('previewShift'),
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
})
})()`)
console.log(out)
} finally {
teardown?.()
}

View file

@ -0,0 +1,158 @@
// Who re-renders the transcript during a sash drag?
//
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
// recording (a) render attribution and (b) every nanostores atom that notifies
// during the gesture. The idle-cost scenario proved the transcript re-renders
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
// (identical counts at 0px and 60px displacement) — so this names the store
// that actually fires.
//
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const TILES = 5
const TURNS = 20
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
])
window.__D__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'diag-tile-' + n
const rid = 'diag-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__D__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Drag, recording renders AND atom notifications together.
const DRAG = `
(async () => {
const rc = window.__RENDER_COUNTS__
const ac = window.__ATOM_CHURN__
rc.start(); ac.start()
const handle = document.querySelector('[role="separator"]')
if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) }
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
rc.stop(); ac.stop()
const all = rc.report(400)
const named = n => all.find(r => r.name === n) || null
return JSON.stringify({
moved: Math.round(x - x0),
commits: rc.commits(),
renders: rc.report(14),
// The suspects: who at the TOP of the transcript tree re-rendered?
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
atoms: ac.report(30)
})
})()
`
const CLEANUP = `
(() => {
if (window.__D__) {
for (const { sid, rid } of window.__D__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__D__ = null
}
return 'cleaned'
})()
`
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`diag-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const data = JSON.parse(await cdp.eval(DRAG))
await cdp.eval(CLEANUP)
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
console.log('RENDERS during drag:')
for (const r of data.renders) {
console.log(
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
)
}
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
for (const { name, hit } of data.chain) {
console.log(
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
)
}
console.log('\nATOMS that notified during drag:')
for (const a of data.atoms) {
console.log(
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
)
}
} finally {
teardown?.()
}

View file

@ -0,0 +1,217 @@
// What is the drag actually spending time on?
//
// The render counters proved React is no longer the cost (commits 83 -> 12
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
// pattern says a fixed per-frame floor outside React. This takes a real CDP
// trace of one sash drag and prints the category split — Recalculate Style,
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
// of the next plausible-looking thing.
//
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const TILES = Number(arg('tiles', 5))
const TURNS = Number(arg('turns', 20))
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
])
window.__T__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'trace-tile-' + n
const rid = 'trace-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__T__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
// loop and make the window itself a function of the slowness. Fixed wall-clock
// pacing keeps the trace window comparable run to run.
const DRAG = `
(async () => {
const handle = document.querySelector('[role="separator"]')
if (!handle) return 'none'
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 40; i++) {
x += (i < 20 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 16))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
return 'dragged'
})()
`
const CLEANUP = `
(() => {
if (window.__T__) {
for (const { sid, rid } of window.__T__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__T__ = null
}
return 'cleaned'
})()
`
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`trace-tile-${n}`))
await sleep(300)
}
await sleep(1500)
// Collect trace events for the drag window only. `cdp.on` is the client's
// only event API (no `once`), so completion is signalled through a flag.
const events = []
let complete = false
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
})
const dragged = await cdp.eval(DRAG)
await cdp.send('Tracing.end')
for (let waited = 0; !complete && waited < 10000; waited += 200) {
await sleep(200)
}
await cdp.eval(CLEANUP)
// Sum self-time per timeline category. Nested events would double-count, so
// attribute each event's duration minus the duration of its direct children.
const INTERESTING = new Set([
'UpdateLayoutTree', // Recalculate Style
'Layout',
'Paint',
'PaintImage',
'Layerize',
'UpdateLayer',
'CompositeLayers',
'FunctionCall',
'EvaluateScript',
'TimerFire',
'EventDispatch',
'HitTest',
'ParseHTML',
'CommitLoad'
])
const totals = new Map()
let traced = 0
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
traced += 1
const name = e.name
if (!INTERESTING.has(name)) {
continue
}
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
}
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
console.log('TIMELINE COST (ms, total duration by event):')
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
if (rows.length === 0) {
console.log(' (no timeline events — category filter or tracing domain unavailable)')
}
for (const [name, ms] of rows) {
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
// Script dominates -> name the functions. Timeline FunctionCall events carry
// the callsite in args.data, so the top offenders can be attributed without
// a separate CPU profile.
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
continue
}
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
console.log('\nTOP SCRIPT CALLSITES (ms):')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}

View file

@ -0,0 +1,47 @@
// Typing latency, isolated: keystroke -> next paint, with and without an
// active stream. Distinguishes "input is slow" from "the frame budget is
// consumed by streaming flushes" — the fix differs completely.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const perKey = []
for (let i = 0; i < 30; i++) {
const ch = 'abcdefghij'[i % 10]
const t0 = performance.now()
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
perKey.push(performance.now() - t0)
// Human-ish 80ms cadence so streaming flushes interleave realistically.
await new Promise(r => setTimeout(r, 80))
}
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const sorted = [...perKey].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
return JSON.stringify({
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
over16: perKey.filter(f => f > 16.7).length,
over33: perKey.filter(f => f > 33).length,
streamingParts: busy
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}

View file

@ -0,0 +1,21 @@
// Quick state probe of the running hgui instance via CDP.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const state = await cdp.eval(`(() => {
const rc = !!window.__RENDER_COUNTS__
const pl = !!window.__PERF_LIVE__
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
})()`)
console.log(state)
} finally {
teardown?.()
}

View file

@ -0,0 +1,149 @@
// The real-app perf loop: drive HER hgui instance (real profile, real
// sessions) through the three interactions that matter — session switch,
// sidebar drag, composer typing — and report honest single-clock numbers.
//
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
//
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
// switching is measured as the user feels it: click -> transcript painted.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const SWITCHES = Number(arg('switches', 6))
const { cdp, teardown } = await attach({ port })
// ---------------------------------------------------------------------------
// Session switch: click a sidebar session row, await the transcript settling.
// Measures click -> first paint of the new transcript AND click -> settled
// (two rAFs with no further DOM mutation in the thread viewport).
// ---------------------------------------------------------------------------
const SWITCH = swaps => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
const results = []
for (let i = 0; i < ${swaps}; i++) {
const row = rows[i % Math.min(rows.length, 4)]
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
const t0 = performance.now()
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
row.click()
// First paint: next two rAFs after the click.
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
const firstPaint = performance.now() - t0
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
let lastMutation = performance.now()
const target = viewport() ?? document.body
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(target, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 120) break
}
mo.disconnect()
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
await new Promise(r => setTimeout(r, 250))
}
return JSON.stringify(results)
})()
`
// ---------------------------------------------------------------------------
// Drag the first visible sash, single-clock frames.
// ---------------------------------------------------------------------------
const DRAG = `
(async () => {
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
if (!handle) return JSON.stringify({ error: 'no sash' })
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const frames = []
let last = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 2 : -2)
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
}
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length
})
})()
`
// ---------------------------------------------------------------------------
// Type into the composer, single-clock frames (one mark per keystroke frame).
// ---------------------------------------------------------------------------
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const frames = []
let last = performance.now()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
await new Promise(r => setTimeout(r, 20))
}
// Clear what we typed.
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const moving = frames
const total = moving.reduce((a, b) => a + b, 0)
const sorted = [...moving].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: moving.filter(f => f > 33).length
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
console.log(await cdp.eval(SWITCH(SWITCHES)))
await sleep(500)
console.log('\n== SIDEBAR DRAG ==')
console.log(await cdp.eval(DRAG))
await sleep(500)
console.log('\n== COMPOSER TYPING ==')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}

View file

@ -0,0 +1,170 @@
// How many ResizeObserver callbacks does one sash drag actually fire, and for
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
// but not whether that's a few expensive calls or a great many cheap ones —
// and the fix differs completely between those.
//
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const TILES = Number(arg('tiles', 5))
const TURNS = Number(arg('turns', 20))
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
])
window.__R__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'ro-tile-' + n
const rid = 'ro-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__R__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Patch ResizeObserver to count callbacks + distinct observed targets, then
// drag and report. Counting happens in the page so nothing crosses CDP per call.
//
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
// lazily on first use, so this patch must be installed BEFORE any surface
// mounts — otherwise the shared instance is a native one this wrapper never
// sees and every counter reads zero. `constructed` is the tell: a run showing
// a handful of constructions and zero callbacks means the patch landed late,
// not that the app stopped observing.
const INSTRUMENT = `
(() => {
if (window.__ROSTATS__) return 'already'
const Native = window.ResizeObserver
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
window.__ROSTATS__ = stats
window.ResizeObserver = class extends Native {
constructor(cb) {
super((entries, obs) => {
if (stats.on) {
stats.callbacks += 1
stats.entries += entries.length
for (const e of entries) stats.targets.add(e.target)
}
return cb(entries, obs)
})
stats.constructed += 1
}
observe(...args) {
stats.observed += 1
return super.observe(...args)
}
}
return 'patched'
})()
`
const DRAG = `
(async () => {
const s = window.__ROSTATS__
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
const handle = document.querySelector('[role="separator"]')
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const t0 = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 40; i++) {
x += (i < 20 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 16))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 300))
s.on = false
return JSON.stringify({
ms: Math.round(performance.now() - t0),
moves: 40,
constructed: s.constructed,
observed: s.observed,
callbacks: s.callbacks,
entries: s.entries,
distinctTargets: s.targets.size,
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
})
})()
`
const CLEANUP = `
(() => {
if (window.__R__) {
for (const { sid, rid } of window.__R__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__R__ = null
}
return 'cleaned'
})()
`
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
await cdp.eval(INSTRUMENT)
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`ro-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const r = JSON.parse(await cdp.eval(DRAG))
await cdp.eval(CLEANUP)
console.log(JSON.stringify(r, null, 2))
if (r.moves) {
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
}
} finally {
teardown?.()
}

View file

@ -0,0 +1,27 @@
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(() => {
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
if (!sidebar) return '(no sidebar el)'
// Find clickable rows: anchors or buttons with text, depth-limited sample.
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
const rows = clickables.map(el => ({
tag: el.tagName.toLowerCase(),
slot: el.getAttribute('data-slot') ?? '',
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
text: (el.textContent ?? '').trim().slice(0, 30),
visible: !!el.offsetParent
})).filter(r => r.text)
return JSON.stringify(rows.slice(0, 30), null, 1)
})()`)
console.log(out)
} finally {
teardown?.()
}

View file

@ -0,0 +1,52 @@
// Session-switch autopsy: click between the two heaviest rows repeatedly,
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
// components — so slow switches name themselves.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const ROUNDS = Number(arg('rounds', 8))
const { cdp, teardown } = await attach({ port })
const SWITCH_ONE = index => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
const row = rows[${index} % 2]
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
row.click()
let lastMutation = performance.now()
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 4000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 150) break
}
mo.disconnect()
rc.stop()
const settled = Math.round(performance.now() - t0 - 150)
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
})()
`
try {
await cdp.send('Runtime.enable')
for (let i = 0; i < ROUNDS; i++) {
console.log(await cdp.eval(SWITCH_ONE(i)))
await sleep(400)
}
} finally {
teardown?.()
}

View file

@ -0,0 +1,74 @@
// What happens during a SLOW session switch? Click a heavy row with tracing
// on, dump the style/layout/script split plus top callsites.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const CLICK_HEAVIEST = `
(() => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return 'need rows'
// Alternate between the first two rows so every run actually switches.
const current = location.hash
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
target.click()
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
})()
`
const events = []
let complete = false
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
try {
await cdp.send('Runtime.enable')
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline'] }
})
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
await cdp.send('Tracing.end')
for (let w = 0; !complete && w < 10000; w += 200) {
await sleep(200)
}
const totals = new Map()
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
if (e.name === 'FunctionCall') {
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
console.log('\nTOP CALLSITES:')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}

View file

@ -1,6 +1,31 @@
// Simple eval helper — runs an expression and returns the result.value.
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
const t = targets.find((t) => t.url.includes('5174'))
//
// node scripts/eval.mjs "document.title"
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
//
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
let targets
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
} catch {
console.error(
`no renderer debugging port on 127.0.0.1:${port}. ` +
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
)
process.exit(1)
}
const t = targets.find((t) => t.url.includes('5174')) ?? targets.find((t) => t.type === 'page')
if (!t) {
console.error(`no page target on 127.0.0.1:${port} (found ${targets.length} target(s))`)
process.exit(1)
}
const ws = new WebSocket(t.webSocketDebuggerUrl)
let id = 0
const pending = new Map()

View file

@ -0,0 +1,290 @@
// Live-drive harness for the REAL hgui instance on :9222.
//
// node scripts/live-drive.mjs status — targets, session count, perf-live armed?
// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4)
// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF
// node scripts/live-drive.mjs type — type into composer, report fps + LoAF
// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms
// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session
// node scripts/live-drive.mjs eval "expr" — arbitrary page eval
//
// Attaches to the page target directly (no perf-harness deps) so it works on
// the app Brooklyn actually runs, with her profile, her sessions, her layout.
import { WebSocket } from 'ws'
const PORT = Number(process.env.CDP_PORT ?? 9222)
async function attach() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url))
if (!page) {
throw new Error('no page target on :' + PORT)
}
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 })
await new Promise((resolve, reject) => {
ws.once('open', resolve)
ws.once('error', reject)
})
let id = 0
const pending = new Map()
ws.on('message', raw => {
const msg = JSON.parse(raw)
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result)
}
})
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const mid = ++id
pending.set(mid, { resolve, reject })
ws.send(JSON.stringify({ id: mid, method, params }))
})
await send('Runtime.enable')
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed')
}
return r.result?.value
}
return { evaluate, close: () => ws.close(), send }
}
const FPS = seconds => `
(async () => {
const frames = []
let last = performance.now()
const end = last + ${seconds * 1000}
while (performance.now() < end) {
await new Promise(r => requestAnimationFrame(r))
const now = performance.now()
frames.push(now - last)
last = now
}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
return {
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length,
n: frames.length
}
})()
`
// LoAF recorder for a window of work driven inside `body`.
const WITH_LOAF = body => `
(async () => {
const lofs = []
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) {
lofs.push({
ms: Math.round(e.duration),
block: Math.round(e.blockingDuration ?? 0),
style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0,
scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s =>
(s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' +
((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms')
})
}
})
po.observe({ type: 'long-animation-frame', buffered: false })
const frames = []
let last = performance.now()
let stop = false
const tick = () => {
if (stop) return
const now = performance.now()
frames.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
${body}
stop = true
po.disconnect()
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
return {
fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0,
p95: Math.round(pct(0.95) * 10) / 10,
worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0,
slow33: frames.filter(f => f > 33).length,
longFrames: lofs.slice(0, 10)
}
})()
`
const DRAG_BODY = `
const handle = document.querySelector('[role="separator"]')
if (!handle) throw new Error('no sash')
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
`
const TYPE_BODY = `
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
if (!el) throw new Error('no composer')
el.focus()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
}
`
// Session switching: click each sidebar session row, time route->settled.
const SWITCH = `
(async () => {
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent !== null).slice(0, 8)
if (pick().length < 2) {
throw new Error('found ' + pick().length + ' session rows')
}
const times = []
const labels = []
for (let i = 0; i < Math.min(pick().length, 6); i++) {
// Re-query each iteration: a switch can re-render the sidebar and
// detach the previously captured nodes.
const row = pick()[i]
if (!row) break
labels.push((row.textContent || '').slice(0, 24))
const t0 = performance.now()
row.click()
let calm = 0
while (calm < 2 && performance.now() - t0 < 5000) {
const f0 = performance.now()
await new Promise(r => requestAnimationFrame(r))
const dt = performance.now() - f0
calm = dt < 20 ? calm + 1 : 0
}
times.push(Math.round(performance.now() - t0))
await new Promise(r => setTimeout(r, 400))
}
return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) }
})()
`
const cmd = process.argv[2] ?? 'status'
const arg = process.argv[3]
const { evaluate, close } = await attach()
try {
if (cmd === 'status') {
const r = await evaluate(`JSON.stringify({
url: location.hash,
perfLive: typeof window.__PERF_LIVE__ !== 'undefined',
renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined',
tiles: document.querySelectorAll('[data-tree-group]').length,
sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length,
composers: [...document.querySelectorAll('[contenteditable="true"]')].length
})`)
console.log(r)
} else if (cmd === 'fps') {
console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4)))))
} else if (cmd === 'drag') {
const r = await evaluate(WITH_LOAF(DRAG_BODY))
console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
for (const lf of r.longFrames) {
console.log(`${lf.ms}ms block=${lf.block} style=${lf.style}${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
}
} else if (cmd === 'type') {
const r = await evaluate(WITH_LOAF(TYPE_BODY))
console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
for (const lf of r.longFrames) {
console.log(`${lf.ms}ms block=${lf.block} style=${lf.style}${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
}
} else if (cmd === 'switch') {
console.log(JSON.stringify(await evaluate(SWITCH)))
} else if (cmd === 'eval') {
console.log(JSON.stringify(await evaluate(arg)))
} else if (cmd === 'profile') {
// CPU-profile one session switch via CDP Profiler (Document Policy blocks
// the in-page Profiler API, CDP is exempt). arg = row label prefix.
await send('Profiler.enable')
await send('Profiler.setSamplingInterval', { interval: 200 })
await send('Profiler.start')
const r = await evaluate(`
(async () => {
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null)
const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')}))
if (!row) return 'row not found'
const t0 = performance.now()
row.click()
let calm = 0
while (calm < 3 && performance.now() - t0 < 6000) {
const f0 = performance.now()
await new Promise(r => requestAnimationFrame(r))
calm = (performance.now() - f0) < 20 ? calm + 1 : 0
}
return Math.round(performance.now() - t0)
})()
`)
const { profile } = await send('Profiler.stop')
// Self-time per function.
const hitById = new Map()
for (let i = 0; i < profile.samples.length; i++) {
const id = profile.samples[i]
const dt = profile.timeDeltas[i] ?? 0
hitById.set(id, (hitById.get(id) ?? 0) + dt)
}
const rows = []
for (const node of profile.nodes) {
const us = hitById.get(node.id)
if (!us || us < 5000) continue
const f = node.callFrame
rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`])
}
rows.sort((a, b) => b[0] - a[0])
console.log('switch took', r, 'ms — top self-time:')
for (const [ms, name] of rows.slice(0, 18)) {
console.log(` ${String(ms).padStart(6)}ms ${name}`)
}
} else if (cmd === 'send') {
const r = await evaluate(`
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
if (!el) return 'no composer'
el.focus()
document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')})
await new Promise(r => setTimeout(r, 120))
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }))
return 'sent'
})()
`)
console.log(r)
} else {
console.log('unknown cmd', cmd)
}
} finally {
close()
}

View file

@ -51,6 +51,8 @@ directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
| `transcript` | ci | large-transcript mount + paint cost | (new) |
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |

View file

@ -1,9 +1,9 @@
{
"_meta": {
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
"platform": "darwin-arm64",
"node": "v24.11.0",
"updated": "2026-07-19T23:16:01.227Z"
"updated": "2026-07-27T00:30:11.290Z"
},
"scenarios": {
"stream": {
@ -55,6 +55,25 @@
"dom_content_loaded_ms": 574,
"nav_to_read_ms": 721
}
},
"multitab": {
"metrics": {
"longtasks_n": 0,
"longtask_max_ms": 0,
"frame_p95_ms": 29.1,
"frame_p99_ms": 36.2,
"slow_frames_33": 9
}
},
"render-churn": {
"metrics": {
"sidebar_renders": 0,
"sidebar_wasted": 0,
"wasted_renders": 1704,
"total_renders": 8221,
"commits": 1352,
"wasted_notifies": 0
}
}
}
}

View file

@ -0,0 +1,284 @@
// What does the app cost while a turn is running but NOTHING is arriving?
//
// The user-visible symptom: with a thread spinning, resizing the sidebar or
// typing in the composer feels slow. That is not streaming cost — the stream
// is idle. It is the app re-rendering on its own, competing with the
// interaction for the main thread.
//
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
// - idle_commits_per_s the renderer's self-inflicted commit rate
// - drag_fps fps while dragging the sidebar splitter
// - type_fps fps while typing in the composer
//
// A perfectly idle app scores 0 idle commits and pins both interactions at the
// display's refresh rate. Every idle commit is main-thread time stolen from an
// interaction the user can feel.
//
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
import { sleep } from '../lib/cdp.mjs'
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
* `render-churn`, but the driver never runs the turn just stays open. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
])
window.__IDLE__ = { ids: [] }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'idle-tile-' + n
const rid = 'idle-rt-' + n
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
// An OPEN assistant message: the turn is running, but no tokens arrive.
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working on it.' }] })
window.__IDLE__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Measure the app's self-inflicted commit rate with nothing happening. */
const idleCost = seconds => `
(async () => {
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
await new Promise(r => setTimeout(r, ${seconds} * 1000))
const elapsed = (performance.now() - t0) / 1000
rc.stop()
return JSON.stringify({
elapsed,
commits: rc.commits(),
top: rc.report(12),
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
})
})()
`
/** Record frame pacing across an interaction driven from the page.
*
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
* clock timing is taken from those same callbacks rather than a second,
* independent rAF ticker. Running two rAF consumers made the observer's
* deltas count the driver's frames as well as the app's and reported ~3fps
* where the interaction actually ran at ~23fps. `frames` is filled by the
* body via `__MARK__`.
*
* `record` MUST be false for any fps number you intend to believe: the render
* counter walks the whole fiber tree on every commit, so recording during a
* gesture measures the instrumentation as much as the app. Attribution and
* timing therefore run as two separate passes. */
const withFrames = (body, record = false) => `
(async () => {
const rc = window.__RENDER_COUNTS__
${record ? 'rc.start()' : ''}
const frames = []
let last = performance.now()
// The body calls this once per frame it drives.
const __MARK__ = () => {
const now = performance.now()
frames.push(now - last)
last = now
}
${body}
${record ? 'rc.stop()' : ''}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
return JSON.stringify({
fps: total ? (frames.length / total) * 1000 : 0,
p95: pct(0.95),
worst: sorted.length ? sorted[sorted.length - 1] : 0,
slow33: frames.filter(f => f > 33).length,
n: frames.length,
commits: ${record ? 'rc.commits()' : '0'},
top: ${record ? 'rc.report(10)' : '[]'}
})
})()
`
/** Drag the sidebar splitter the resize symptom.
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
* and reports how far it actually moved so a drag that silently did nothing
* shows up as `dragMoved: 0` instead of a confident wrong number. */
const DRAG = withFrames(`
const handle = document.querySelector('[role="separator"]')
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
window.__DRAG_MOVED__ = 0
if (handle) {
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
// Out 60px then back — a real gesture, with a net displacement at the peak.
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
__MARK__()
}
window.__DRAG_MOVED__ = Math.round(x - x0)
for (let i = 0; i < 30; i++) {
x -= 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
__MARK__()
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
/** Type into the composer — the keystroke symptom. */
const TYPE = withFrames(`
const el = document.querySelector('[contenteditable="true"], textarea')
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
if (el) {
el.focus()
for (let i = 0; i < 40; i++) {
const ch = 'performance testing '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
if (el.tagName === 'TEXTAREA') {
el.value += ch
el.dispatchEvent(new Event('input', { bubbles: true }))
} else {
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
}
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
// Wait for the frame this keystroke produces, then mark it — same clock
// discipline as DRAG, so typing fps is comparable to drag fps.
await new Promise(r => requestAnimationFrame(r))
__MARK__()
await new Promise(r => setTimeout(r, 25))
}
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
const CLEANUP = `
(() => {
if (window.__IDLE__) {
for (const { sid, rid } of window.__IDLE__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__IDLE__ = null
}
window.__RENDER_COUNTS__.clear()
return 'cleaned'
})()
`
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
export default {
name: 'idle-cost',
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
// direct single-clock probe of the same gesture on the same build (57fps),
// and I could not reconcile the two — ruled out sash selection, tile setup,
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
// rate are trustworthy and are what this scenario is for; the interaction
// fps is reported for investigation, not gated on, until that is explained.
tier: 'report',
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const seconds = Number(opts.seconds ?? 6)
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
}
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`idle-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
const drag = JSON.parse(await cdp.eval(DRAG))
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
const type = JSON.parse(await cdp.eval(TYPE))
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
await cdp.eval(CLEANUP)
if (dragTarget === 'none') {
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
}
if (typeTarget === 'none') {
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
}
return {
metrics: {
// Commits per second with a turn open and nothing arriving. Should be 0.
idle_commits_per_s: round(idle.commits / idle.elapsed),
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
// Interaction smoothness while that churn competes for the main thread.
// Reported as a deficit from 60fps so "lower is better" matches the
// baseline gate's direction.
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
drag_slow_frames: drag.slow33,
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
type_slow_frames: type.slow33
},
detail: {
tiles,
dragTarget,
dragMoved,
idleSeconds: round(idle.elapsed),
dragFps: round(drag.fps),
dragP95: round(drag.p95),
dragWorst: round(drag.worst),
typeFps: round(type.fps),
typeP95: round(type.p95),
typeWorst: round(type.worst),
// Components whose OWN state changed with no prop change: the roots.
idleOwners: idle.owners,
idleTop: idle.top,
dragCommits: drag.commits,
dragTop: drag.top,
typeCommits: type.commits,
typeTop: type.top
}
}
}
}

View file

@ -3,9 +3,11 @@
import coldStart from './cold-start.mjs'
import firstToken from './first-token.mjs'
import idleCost from './idle-cost.mjs'
import keystroke from './keystroke.mjs'
import multitab from './multitab.mjs'
import profileSwitch from './profile-switch.mjs'
import renderChurn from './render-churn.mjs'
import sessionSwitch from './session-switch.mjs'
import stream from './stream.mjs'
import streamHistory from './stream-history.mjs'
@ -18,6 +20,8 @@ export const SCENARIOS = {
[keystroke.name]: keystroke,
[transcript.name]: transcript,
[multitab.name]: multitab,
[renderChurn.name]: renderChurn,
[idleCost.name]: idleCost,
[coldStart.name]: coldStart,
[firstToken.name]: firstToken,
[submit.name]: submit,

View file

@ -0,0 +1,259 @@
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
// store published the update. Frame pacing (see `multitab`) tells you the cost;
// this tells you the cause.
//
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
// then reads the dev-only counters installed by `src/debug/`:
//
// window.__RENDER_COUNTS__ — per-component renders, attributed to
// props / hook state / parent-only ("wasted")
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
// notifications whose value was deep-equal to the
// previous one ("wasted")
//
// The headline metric is `sidebar_renders`: how many times the sidebar tree
// re-rendered while agents were typing in other tabs. It should be 0.
//
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
import { sleep } from '../lib/cdp.mjs'
/** Components that make up the sidebar tree. A render of any of these while
* a background tab streams is work the user cannot see. */
const SIDEBAR_COMPONENTS = [
'ChatSidebar',
'SidebarSurface',
'SessionRow',
'SessionsSection',
'CronJobsSection',
'ProfileSwitcher',
'VirtualSessionList',
'WorkspaceGroup',
'OverviewRow',
'SessionStatusDot'
]
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
])
const state = (sid) => {
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: '' }] })
return {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
}
}
window.__RC__ = { ids: [], timer: null }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'churn-tile-' + n
const rid = 'churn-rt-' + n
window.__RC__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, state(sid))
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
* same publish path the gateway's delta flush uses. */
const drive = (chunk, intervalMs, totalTokens) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
let pushed = 0
const tick = () => {
const states = hook.states()
for (const { rid } of window.__RC__.ids) {
const prev = states[rid]
if (!prev) continue
const messages = prev.messages.map(m => {
if (m.id !== prev.streamId) return m
const head = m.parts.slice(0, -1)
const last = m.parts[m.parts.length - 1]
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
})
hook.publish(rid, { ...prev, messages })
}
pushed += 1
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
else window.__RC__.done = true
}
window.__RC__.timer = setTimeout(tick, ${intervalMs})
return 'driving'
})()
`
/** Wait until the renderer stops committing on its own, so the recording window
* captures STREAMING cost and not whatever boot/hydration work happened to
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
*
* If it returns `timeout:...` the app never went idle at all with tiles
* marked busy and NO driver running, that means something is ticking on its
* own. The report of what rendered during the wait is attached so the culprit
* is named rather than guessed at. */
const quiesce = (quietMs, timeoutMs) => `
(async () => {
const rc = window.__RENDER_COUNTS__
rc.start()
const deadline = Date.now() + ${timeoutMs}
const startedAt = Date.now()
let last = -1
let stableSince = Date.now()
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 100))
const n = rc.commits()
if (n !== last) { last = n; stableSince = Date.now(); continue }
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
}
const idle = {
commits: last,
seconds: (Date.now() - startedAt) / 1000,
top: rc.report(8),
// Who OWNS the update? The component whose own hook state changed with
// no changed props is the root of a churn cascade; everything under it
// is collateral. Naming it is the difference between fixing the cause
// and memoizing a symptom.
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
}
rc.stop()
return 'timeout:' + JSON.stringify(idle)
})()
`
const START = `
(() => {
window.__RENDER_COUNTS__.start()
window.__ATOM_CHURN__.start()
return 'recording'
})()
`
const COLLECT = `
(() => {
window.__RENDER_COUNTS__.stop()
window.__ATOM_CHURN__.stop()
return JSON.stringify({
commits: window.__RENDER_COUNTS__.commits(),
renders: window.__RENDER_COUNTS__.report(200),
atoms: window.__ATOM_CHURN__.report(200)
})
})()
`
const CLEANUP = `
(() => {
if (window.__RC__) {
clearTimeout(window.__RC__.timer)
for (const { sid, rid } of window.__RC__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__RC__ = null
}
window.__RENDER_COUNTS__.clear()
window.__ATOM_CHURN__.clear()
return 'cleaned'
})()
`
export default {
name: 'render-churn',
tier: 'ci',
description: 'N streaming tabs: per-component render attribution + store churn.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const tokens = Number(opts.tokens ?? 240)
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
const intervalMs = Number(opts.intervalMs ?? 33)
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
)
}
// Mount every tab (keep-alive mounts on first activation), then settle.
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`churn-tile-${n}`))
await sleep(350)
}
// Let the app go quiet before recording, so boot/hydration commits that
// happen to still be in flight don't land in the streaming window. This is
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
// leak in depending on machine load.
const settle = await cdp.eval(quiesce(600, 15000))
await cdp.eval(START)
await cdp.eval(drive(chunk, intervalMs, tokens))
await sleep(tokens * intervalMs + 1500)
const data = JSON.parse(await cdp.eval(COLLECT))
await cdp.eval(CLEANUP)
const byName = new Map(data.renders.map(r => [r.name, r]))
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
return {
metrics: {
// The hypothesis, as a number: sidebar renders while background tabs
// stream. Should be 0.
sidebar_renders: sidebarRenders,
sidebar_wasted: sidebarWasted,
// Renders with no changed props and no changed hook state — pure
// parent-driven work, across the whole tree.
wasted_renders: totalWasted,
total_renders: totalRenders,
commits: data.commits,
// Store notifications that published a value equal to the last one.
wasted_notifies: atomWasted
},
detail: {
tiles,
tokens,
// 'quiet:N' = the app went idle before recording (comparable run).
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
settle,
sidebar: sidebarRows,
topRenders: data.renders.slice(0, 15),
topAtoms: data.atoms.slice(0, 15)
}
}
}
}

View file

@ -19,7 +19,15 @@ import { RowButton } from '@/components/ui/row-button'
import { Tip } from '@/components/ui/tooltip'
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
import { resolveBrandIcon } from '@/lib/brand-icon'
import {
ExternalLink,
ExternalLinkIcon,
hostPathLabel,
shortHostLabel,
urlSlugTitleLabel,
useLinkTitle
} from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
import { normalize } from '@/lib/text'
@ -29,8 +37,8 @@ import { notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { openSession } from '../open-session'
import { PageSearchShell } from '../page-search-shell'
import { sessionRoute } from '../routes'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import {
@ -272,7 +280,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
const cellCtx: CellCtx = {
onOpen: openArtifact,
onOpenChat: sessionId => navigate(sessionRoute(sessionId))
onOpenChat: sessionId => openSession(sessionId, navigate)
}
return (
@ -337,7 +345,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
failedImage={failedImageIds.has(artifact.id)}
key={artifact.id}
onImageError={markImageFailed}
onOpenChat={sessionId => navigate(sessionRoute(sessionId))}
onOpenChat={sessionId => openSession(sessionId, navigate)}
/>
))}
</div>
@ -543,7 +551,8 @@ function ArtifactCellAction({
function PrimaryCell({ artifact, ctx }: { artifact: ArtifactRecord; ctx: CellCtx }) {
const isLink = artifact.kind === 'link'
const Icon = isLink ? Link2 : FileText
const brand = isLink ? resolveBrandIcon(shortHostLabel(artifact.href)) : null
const Icon = brand ?? (isLink ? Link2 : FileText)
const fetchedTitle = useLinkTitle(isLink ? artifact.href : null)
const label = isLink ? fetchedTitle || urlSlugTitleLabel(artifact.href) : artifact.label

View file

@ -1,14 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
import {
$filePreviewTabs,
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { closeActiveTab } from './close-tab'
@ -26,40 +19,34 @@ function fileTarget(path: string): PreviewTarget {
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
closeRightRail()
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
vi.unstubAllGlobals()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearSessionPreviewRegistry()
closeRightRail()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
openPreview(fileTarget('/work/notes.md'), 'manual')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($previewTabs.get()).toHaveLength(1)
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
expect($previewTabs.get()).toHaveLength(0)
})
it('closes the visible file tab when active selection is a ghost preview', () => {
// Active tab id stuck on live-preview after that target was cleared, while
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
$previewTarget.set(null)
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
it('closes the visible tab when the active selection points at a tab that is gone', () => {
// The rail falls back to tabs[0] until React syncs the selection, so ⌘W has
// to act on what is actually on screen rather than no-op'ing.
openPreview(fileTarget('/work/notes.md'), 'manual')
$rightRailActiveTabId.set('file:file:///work/stale.md')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($previewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
expect($previewTabs.get()).toHaveLength(0)
})
})

View file

@ -1,21 +1,24 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone its active tab (a session tile stacked into the workspace).
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
* 3. the FOCUSED chat zone its active tab (a session tile stacked into it).
* 4. the workspace tab itself, when session tabs are stacked with it:
* the workspace can't close, so W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* Returns false when nothing closes, so W is a no-op it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
*
* Steps 3-4 follow the same focused zone 19 indexes, so a second chat zone
* with its own tab strip closes ITS tab instead of main's.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (W stays the pre-existing no-op on the main tab).
@ -27,18 +30,16 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
return true
}
// Prefer tab *presence* over the derived active file target. After the live
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
// broken with a file tab still on screen.
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
// Gate on tab *presence*, not on the selection: a stale `$rightRailActiveTabId`
// would otherwise make ⌘W fall through to closeFocusedSessionTab() and look
// broken with a tab still on screen. The store resolves which tab that is.
if ($previewTabs.get().length > 0) {
return closeActiveRightRailTab()
}
// A closeable main-zone tab (a session tile that's the active tab) closes
// outright; the uncloseable workspace tab returns false and falls through.
if (closeWorkspaceTab()) {
// A closeable tab in the focused chat zone (a session tile that's the active
// tab) closes outright; the uncloseable workspace tab falls through.
if (closeFocusedSessionTab()) {
return true
}

View file

@ -0,0 +1,180 @@
import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { act, renderHook } from '@testing-library/react'
import { createRef } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useComposerTrigger } from './hooks/use-composer-trigger'
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
/**
* Folder navigation in the `@` popover, driven through the REAL hook against a
* real contentEditable.
*
* Tab and Enter used to be the same branch, so picking a folder always
* committed a chip and closed the menu there was no way to walk into a
* subdirectory from the list. Tab now descends, Enter still commits, and
* Backspace climbs back out one segment.
*/
function folderItem(path: string): Unstable_TriggerItem {
return {
id: `@folder:${path}|0`,
type: 'folder',
label: path.split('/').filter(Boolean).pop() ?? path,
metadata: {
icon: 'folder',
display: path,
meta: 'dir',
rawText: `@folder:${path}`,
insertId: path
}
}
}
function setup(initialText: string) {
const editor = document.createElement('div')
editor.contentEditable = 'true'
// The real composer marks its editor with this slot; `composerPlainText`
// keys off it to decide whether a DIV contributes a trailing newline.
// Without it the harness would silently diverge from production text.
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
renderComposerContents(editor, initialText)
// Caret at the end, which is where a typed trigger always leaves it.
const range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
const editorRef = createRef<HTMLDivElement>() as { current: HTMLDivElement | null }
editorRef.current = editor
const draftRef = { current: initialText }
const setComposerText = vi.fn()
const { result } = renderHook(() =>
useComposerTrigger({
at: { adapter: null, loading: false },
draftRef,
editorRef,
requestMainFocus: vi.fn(),
setComposerText,
slash: { adapter: null, loading: false }
})
)
act(() => {
result.current.refreshTrigger()
})
return { editor, result, setComposerText }
}
describe('@ folder navigation', () => {
it('Tab on a folder walks into it and keeps the popover open', () => {
const { editor, result } = setup('@app')
expect(result.current.trigger).toMatchObject({ kind: '@', query: 'app' })
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
// Plain text, not a chip — the token is still being typed.
expect(composerPlainText(editor)).toBe('@apps/')
expect(editor.querySelector('[data-ref-text]')).toBeNull()
})
it('descends repeatedly, one level per Tab', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })
})
expect(composerPlainText(editor)).toBe('@apps/desktop/')
})
it('Enter on a folder commits it as a chip instead of descending', () => {
const { editor, result } = setup('@app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'))
})
const chip = editor.querySelector('[data-ref-text]')
expect(chip).not.toBeNull()
expect(chip?.getAttribute('data-ref-kind')).toBe('folder')
expect(composerPlainText(editor)).toContain('@folder:')
})
it('only descends for `@` folders — a file pick still commits', () => {
const { editor, result } = setup('@main')
const file: Unstable_TriggerItem = {
id: '@file:src/main.tsx|0',
type: 'file',
label: 'main.tsx',
metadata: {
icon: 'file',
display: 'main.tsx',
meta: 'src',
rawText: '@file:src/main.tsx',
insertId: 'src/main.tsx'
}
}
act(() => {
result.current.replaceTriggerWithChip(file, { descend: true })
})
expect(editor.querySelector('[data-ref-kind="file"]')).not.toBeNull()
})
it('Backspace climbs out one segment at a time', () => {
const { editor, result } = setup('@apps/desktop/')
let handled = false
act(() => {
handled = result.current.ascendTriggerPath()
})
expect(handled).toBe(true)
expect(composerPlainText(editor)).toBe('@apps/')
})
it('Backspace drops a partially typed segment before its parent', () => {
const { editor, result } = setup('@apps/desk')
act(() => {
result.current.ascendTriggerPath()
})
expect(composerPlainText(editor)).toBe('@apps/')
})
it('leaves Backspace alone when there is no path to climb', () => {
const { result } = setup('@apps')
let handled = true
act(() => {
handled = result.current.ascendTriggerPath()
})
// No `/` in the query — normal character deletion must still happen.
expect(handled).toBe(false)
})
it('preserves text typed before the mention', () => {
const { editor, result } = setup('look at @app')
act(() => {
result.current.replaceTriggerWithChip(folderItem('apps'), { descend: true })
})
expect(composerPlainText(editor)).toBe('look at @apps/')
})
})

View file

@ -1,11 +1,14 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import type { ComposerAttachment } from '@/store/composer'
import { $previewTabs } from '@/store/preview'
import { AttachmentList } from './attachments'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
}
@ -66,4 +69,43 @@ describe('AttachmentList', () => {
expect(screen.getByText('valid.txt')).toBeDefined()
})
it('opens an attached image in the lightbox, not the preview rail', async () => {
$previewTabs.set([])
const image: ComposerAttachment = {
id: 'img',
kind: 'image',
label: 'shot.png',
path: '/tmp/shot.png',
previewUrl: DATA_URL
}
await renderWithI18n(<AttachmentList attachments={[image]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /shot\.png/ }))
})
// The lightbox renders the full-size image in a dialog; the rail stays empty.
const lightbox = await screen.findByRole('dialog')
expect(lightbox.querySelector<HTMLImageElement>('img')?.src).toBe(DATA_URL)
expect($previewTabs.get()).toHaveLength(0)
})
it('still routes a non-image attachment to the preview rail', async () => {
$previewTabs.set([])
const file: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.md', path: '/tmp/notes.md' }
await renderWithI18n(<AttachmentList attachments={[file]} />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /notes\.md/ }))
})
expect(screen.queryByRole('dialog')).toBeNull()
expect($previewTabs.get().map(tab => tab.target.path)).toEqual(['/tmp/notes.md'])
})
})

View file

@ -1,15 +1,18 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ImageLightbox } from '@/components/chat/zoomable-image'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useImageDownload } from '@/hooks/use-image-download'
import { useI18n } from '@/i18n'
import { AlertCircle, FileText, FolderOpen, ImageIcon, Link, Loader2, Terminal } from '@/lib/icons'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
import { openPreview } from '@/store/preview'
export function AttachmentList({
attachments,
@ -31,17 +34,32 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const { t } = useI18n()
const c = t.composer
const Icon = { folder: FolderOpen, url: Link, image: ImageIcon, file: FileText, terminal: Terminal }[attachment.kind]
const cwd = useStore($currentCwd)
// The tile's cwd when this pill lives in a tile composer, not the primary's:
// a relative attachment path has to resolve against its own session's root.
const cwd = useStore(useSessionView().$cwd)
const isUploading = attachment.uploadState === 'uploading'
const hasUploadError = attachment.uploadState === 'error'
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
// An attached image already holds its full bytes as a data URL, so it belongs
// in the same lightbox the thread uses. The rail is for files you read or
// edit — not a picture you just want to look at. Images that never resolved a
// thumbnail still fall through to the rail rather than dead-clicking.
const lightboxSrc = attachment.kind === 'image' && !isUploading ? attachment.previewUrl : undefined
const [lightboxOpen, setLightboxOpen] = useState(false)
const { download, saving } = useImageDownload(lightboxSrc)
async function openPreview() {
async function openAttachment() {
if (!canPreview) {
return
}
if (lightboxSrc) {
setLightboxOpen(true)
return
}
const rawTarget =
attachment.path ||
attachment.detail ||
@ -62,85 +80,90 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
throw new Error(c.couldNotPreview(attachment.label))
}
// We already hold the image bytes (the card thumbnail) — render those
// directly so a screenshot/clipboard image previews even when its only
// on-disk copy is a transient path the renderer can't re-read.
const withBytes =
attachment.kind === 'image' && attachment.previewUrl
? { ...preview, dataUrl: attachment.previewUrl, previewKind: 'image' as const }
: preview
setCurrentSessionPreviewTarget(withBytes, 'manual', target)
openPreview(preview, 'manual')
} catch (error) {
notifyError(error, c.previewUnavailable)
}
}
return (
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openPreview()}
type="button"
>
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<Icon className="size-3.5" />
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
{detail}
</span>
)}
</span>
</button>
{onRemove && (
<>
<Tip label={attachment.path || attachment.detail || attachment.label}>
<div className="group/attachment relative min-w-0 shrink-0">
<button
aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
aria-busy={isUploading || undefined}
aria-label={canPreview ? c.previewLabel(attachment.label) : attachment.label}
className={cn(
'flex max-w-56 items-center gap-2 rounded-2xl border bg-background/50 px-2 py-1.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.18)] transition-colors disabled:cursor-default',
hasUploadError
? 'border-destructive/45 hover:border-destructive/60'
: 'border-border/60 hover:border-primary/35 hover:bg-accent/45'
)}
disabled={!canPreview}
onClick={() => void openAttachment()}
type="button"
>
<Codicon name="close" size="0.625rem" />
<span className="relative grid size-8 shrink-0 place-items-center overflow-hidden rounded-lg border border-border/55 bg-muted/35 text-muted-foreground">
{attachment.previewUrl && attachment.kind === 'image' ? (
<img
alt={attachment.label}
className="size-full object-cover"
draggable={false}
src={attachment.previewUrl}
/>
) : (
<Icon className="size-3.5" />
)}
{isUploading && (
<span className="absolute inset-0 grid place-items-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="size-3.5 animate-spin text-foreground/75" />
</span>
)}
{hasUploadError && (
<span className="absolute inset-0 grid place-items-center bg-destructive/15">
<AlertCircle className="size-3.5 text-destructive" />
</span>
)}
</span>
<span className="min-w-0">
<span className="block truncate text-[0.72rem] font-medium leading-4 text-foreground/90">
{attachment.label}
</span>
{detail && (
<span
className={cn(
'block truncate text-[0.62rem] leading-3.5',
hasUploadError ? 'text-destructive/80' : 'text-muted-foreground/65'
)}
>
{detail}
</span>
)}
</span>
</button>
)}
</div>
</Tip>
{onRemove && (
<button
aria-label={c.removeAttachment(attachment.label)}
className="absolute -right-1 -top-1 grid size-3.5 place-items-center rounded-full border border-border/70 bg-background text-muted-foreground opacity-0 shadow-xs transition hover:bg-accent hover:text-foreground group-hover/attachment:opacity-100 focus-visible:opacity-100"
onClick={() => onRemove(attachment.id)}
type="button"
>
<Codicon name="close" size="0.625rem" />
</button>
)}
</div>
</Tip>
{lightboxSrc && (
<ImageLightbox
alt={attachment.label}
copy={t.desktop}
onClick={download}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
saving={saving}
src={lightboxSrc}
/>
)}
</>
)
}

View file

@ -2,12 +2,14 @@ import type { Unstable_TriggerItem } from '@assistant-ui/core'
import { describe, expect, it } from 'vitest'
import {
acceptsTriggerCompletion,
isPendingDraftPersistCurrent,
type PendingDraftPersist,
pickPlaceholder,
slashArgStage,
slashChipKindForItem,
slashCommandToken
slashCommandToken,
type TriggerAcceptInput
} from './composer-utils'
const item = (group: string): Unstable_TriggerItem =>
@ -39,6 +41,53 @@ describe('slashChipKindForItem', () => {
})
})
describe('acceptsTriggerCompletion', () => {
const press = (key: string, overrides: Partial<TriggerAcceptInput> = {}) =>
acceptsTriggerCompletion({
activeExplicit: false,
freeTextArgStage: false,
key,
kind: '/',
query: 'personality alic',
...overrides
})
it('accepts on Enter / Tab / Space for a finite option list', () => {
expect(press('Enter')).toBe(true)
expect(press('Tab')).toBe(true)
expect(press(' ')).toBe(true)
})
it('ignores keys that are neither navigation nor acceptance', () => {
expect(press('a')).toBe(false)
expect(press('Escape')).toBe(false)
})
it('lets an `@` mention take a literal space', () => {
expect(press(' ', { kind: '@', query: 'src/comp' })).toBe(false)
expect(press('Enter', { kind: '@', query: 'src/comp' })).toBe(true)
})
it('types a space on a bare `/ ` instead of accepting', () => {
expect(press(' ', { query: '' })).toBe(false)
})
// The `/goal <prose>` class: the popover may be live over free-form text, so
// the keys that mean something else in prose must keep meaning it.
it('sends the prose rather than the unchosen first row', () => {
expect(press('Enter', { freeTextArgStage: true, query: 'goal ship the redesign' })).toBe(false)
expect(press(' ', { freeTextArgStage: true, query: 'goal ship the' })).toBe(false)
})
it('accepts on Enter once the user has arrowed to a row deliberately', () => {
expect(press('Enter', { activeExplicit: true, freeTextArgStage: true, query: 'goal stat' })).toBe(true)
})
it('keeps Tab as the explicit accept even over free text', () => {
expect(press('Tab', { freeTextArgStage: true, query: 'goal stat' })).toBe(true)
})
})
describe('pickPlaceholder', () => {
it('returns a member of the pool', () => {
const pool = ['a', 'b', 'c'] as const

View file

@ -4,6 +4,8 @@ import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
import type { ComposerAttachment } from '@/store/composer'
import { setSessionPickerOpen } from '@/store/session'
import type { TriggerState } from './text-utils'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
// Above the stack breakpoint but still cramped: the model pill sheds its label
@ -59,6 +61,48 @@ export const slashArgStage = (query: string) => query.includes(' ')
/** The `/command` token of a slash query (`personality x` → `/personality`). */
export const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
export interface TriggerAcceptInput {
/** The user moved the highlight themselves (arrow keys) rather than
* inheriting the list's default first row. */
activeExplicit: boolean
/** The trigger is a slash command whose argument is arbitrary prose. */
freeTextArgStage: boolean
key: string
kind: TriggerState['kind']
query: string
}
/**
* Whether a keypress accepts the highlighted completion while the popover is
* open. Tab is always an accept it has no other meaning in the composer.
*
* Enter and Space are conditional, because both mean something else while a
* free-text argument is being written (`/goal ship the redesign`). Space types
* a space, and Enter sends the message; letting either take the popover's
* pre-highlighted row would swap the prose the user is mid-sentence on for a
* subcommand they never chose. Enter still accepts once the user has arrowed
* to a row deliberately, so the highlight never lies about what Enter will do.
*/
export function acceptsTriggerCompletion({
activeExplicit,
freeTextArgStage,
key,
kind,
query
}: TriggerAcceptInput): boolean {
if (key === 'Tab') {
return true
}
if (key === 'Enter') {
return !freeTextArgStage || activeExplicit
}
// Space is slash-only (an `@` mention takes a literal space) and gated to a
// non-empty query so a bare `/ ` still types a space.
return key === ' ' && kind === '/' && Boolean(query.trim()) && !freeTextArgStage
}
export interface QueueEditState {
attachments: ComposerAttachment[]
draft: string

View file

@ -29,7 +29,8 @@ function Harness({
onSubmit,
onQueue,
onCancel,
onDrain
onDrain,
onSendNow
}: {
busy?: boolean
disabled?: boolean
@ -38,6 +39,7 @@ function Harness({
onQueue: (text: string) => void
onCancel: () => void
onDrain: () => void
onSendNow?: (id: string) => void
}) {
const editorRef = useRef<HTMLDivElement>(null)
const draftRef = useRef('')
@ -103,6 +105,12 @@ function Harness({
}
if (busy && !hasLivePayload) {
const head = queued[0]
if (head) {
onSendNow?.(head)
}
return
}
@ -167,13 +175,14 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onCancel).not.toHaveBeenCalled()
})
it('treats an empty Enter while busy as a no-op (never an accidental Stop)', async () => {
it('treats an empty Enter while busy with nothing queued as a no-op (never an accidental Stop)', async () => {
const onCancel = vi.fn()
const onSubmit = vi.fn()
const onQueue = vi.fn()
const onSendNow = vi.fn()
const { getByTestId } = render(
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSubmit={onSubmit} />
<Harness busy onCancel={onCancel} onDrain={vi.fn()} onQueue={onQueue} onSendNow={onSendNow} onSubmit={onSubmit} />
)
const editor = getByTestId('editor')
@ -186,6 +195,35 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onQueue).not.toHaveBeenCalled()
expect(onSendNow).not.toHaveBeenCalled()
})
it('double-send: an empty Enter while busy with a queued turn sends that turn now', async () => {
const onCancel = vi.fn()
const onSendNow = vi.fn()
const { getByTestId } = render(
<Harness
busy
onCancel={onCancel}
onDrain={vi.fn()}
onQueue={vi.fn()}
onSendNow={onSendNow}
onSubmit={vi.fn()}
queued={['queued-1', 'queued-2']}
/>
)
const editor = getByTestId('editor')
await act(async () => {
editor.textContent = ''
fireEvent.keyDown(editor, { key: 'Enter' })
})
// Head of the queue, and NOT a bare cancel — send-now promotes + interrupts.
expect(onSendNow).toHaveBeenCalledWith('queued-1')
expect(onCancel).not.toHaveBeenCalled()
})
it('drains the next queued prompt on Enter when idle with a truly empty editor', async () => {

View file

@ -4,10 +4,11 @@ import { useEffect, useRef } from 'react'
import { playSpeechText } from '@/lib/voice-playback'
import { ownsAmbientCue } from '@/store/ambient'
import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $voicePlayback } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useComposerScope } from '../scope'
interface AutoSpeakReply {
id: string
pending: boolean
@ -40,6 +41,9 @@ export function useAutoSpeakReplies({
sessionId
}: UseAutoSpeakReplies) {
const enabled = useStore($autoSpeakReplies)
// Wake on THIS composer's transcript: a tile subscribed to the primary's
// would never fire on its own replies (and would fire on someone else's).
const { $messages } = useComposerScope()
const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
@ -83,5 +87,5 @@ export function useAutoSpeakReplies({
const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
return () => stops.forEach(f => f())
}, [enabled, sessionId])
}, [$messages, enabled, sessionId])
}

View file

@ -408,6 +408,7 @@ export function useComposerDraft({
requestMainFocus,
sessionIdRef,
setComposerText,
stashAt
stashAt,
syncDraftFromEditor
}
}

View file

@ -9,8 +9,6 @@ import {
} from '@/app/chat/surface-vars'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { $composerPoppedOut } from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
@ -82,6 +80,11 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
const lastBucketedSurfaceHeightRef = useRef(0)
const lastTightRef = useRef<boolean | null>(null)
const lastCompactPillRef = useRef<boolean | null>(null)
// Mirrored into a ref so `syncComposerMetrics` stays referentially stable —
// it's the shared ResizeObserver's handler, and a new identity every render
// would re-register the observation.
const poppedOutRef = useRef(poppedOut)
poppedOutRef.current = poppedOut
const syncComposerMetrics = useCallback(() => {
const composer = composerRef.current
@ -92,9 +95,10 @@ export function useComposerMetrics({ composerRef, composerSurfaceRef, editorRef,
// Floating composer is out of the thread's flow — it must not reserve any
// bottom clearance. Zero the measured vars so the thread reclaims the space.
// (Read globals here so the callback stays stable; mirror the popoutAllowed
// gate since secondary windows are forced docked.)
if ($composerPoppedOut.get() && !isSecondaryWindow()) {
// Read through a ref so the callback stays stable, and read THIS surface's
// own state: pop-out is per layout zone, so a float in the left split must
// not zero the right split's clearance.
if (poppedOutRef.current) {
lastBucketedHeightRef.current = 0
lastBucketedSurfaceHeightRef.current = 0
setSurfaceVar(composer, COMPOSER_HEIGHT_VAR, '0px')

View file

@ -1,18 +1,20 @@
import { useStore } from '@nanostores/react'
import { type RefObject, useCallback, useEffect } from 'react'
import { type RefObject, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { usePaneGroup, usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { triggerHaptic } from '@/lib/haptics'
import {
$composerPopoutPosition,
$composerPoppedOut,
$composerPopoutZone,
clampPopoutPosition,
getComposerPopoutZone,
popoutBoundsElement,
type PopoutPosition,
readPopoutBounds,
setComposerPopoutPosition,
setComposerPoppedOut
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
import { useComposerScope } from '../scope'
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@ -20,32 +22,122 @@ interface UseComposerPopoutOptions {
}
/**
* Pop-out engine: the dockedfloating state (a shared, persisted atom), the
* dock/float/toggle actions, the drag gestures, and the on-screen re-clamp.
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) can't
* pop out a floating composer makes no sense there and would yank the main
* window's composer out via the shared atom.
* This surface's on-screen placement, derived from its zone's drag intent.
*
* A zone stores one intent for its whole tab stack drag the box in any tab and
* it moves in all of them but each surface owns a different rect, so the
* intent is clamped per surface. Clamping into the store instead would have
* every keep-alive-mounted tab overwrite the others with a position bounded by
* ITS geometry, last writer winning: that's how a drag in one tab used to get
* lost in another.
*
* Re-placing is skipped while this surface drags (the gesture already clamped
* against this rect) and while it's an inactive tab (still mounted, so a live
* drag would otherwise force a reflow per background tab per frame).
*/
function usePopoutPlacement(
composerRef: RefObject<HTMLFormElement | null>,
groupId: string,
intent: PopoutPosition,
dragging: boolean,
poppedOut: boolean
): PopoutPosition {
const [placement, setPlacement] = useState(intent)
const visible = usePaneVisible()
// Re-place while this surface is the visible tab and isn't itself dragging.
const live = poppedOut && visible && !dragging
// Resolved before the shared ResizeObserver below registers (hook order puts
// this layout effect first), so the observer always has this surface's own
// bounds element rather than a document-wide first match.
const boundsRef = useRef<Element | null>(null)
useLayoutEffect(() => {
boundsRef.current = popoutBoundsElement(composerRef.current)
})
const reclamp = useCallback(() => {
const el = composerRef.current
if (!el) {
return
}
const size = { height: el.offsetHeight, width: el.offsetWidth }
const next = clampPopoutPosition(getComposerPopoutZone(groupId).position, size, readPopoutBounds(el))
// Bail on an unchanged placement: a sash drag resizes the surface every
// frame, and a fresh object each time re-renders the whole composer.
setPlacement(prev => (prev.bottom === next.bottom && prev.right === next.right ? prev : next))
}, [composerRef, groupId])
// The surface resizing (sash drag, sidebar open, tab split) re-places the box
// against its new rect; the composer resizing (a growing draft) re-places it
// against its new height.
useResizeObserver(
useCallback(() => {
if (live) {
reclamp()
}
}, [live, reclamp]),
composerRef,
boundsRef
)
// useLayoutEffect, not useEffect: a tab revealed after the box was dragged in
// another one must not paint a frame at its stale placement before catching
// up. Runs before paint, and no-ops for hidden tabs (`live`).
useLayoutEffect(() => {
if (!live) {
return undefined
}
reclamp()
// A second pass after layout settles (sidebar widths, fonts): anyone
// restored out of bounds is pulled back even if the first measure was
// premature.
const raf = requestAnimationFrame(reclamp)
window.addEventListener('resize', reclamp)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', reclamp)
}
}, [intent, live, reclamp])
return dragging ? intent : placement
}
/**
* Pop-out engine: the dockedfloating state, the dock/float/toggle actions, the
* drag gestures, and this surface's placement.
*
* State is scoped to the surface's layout ZONE (its tab stack): tabs in the same
* zone share one float, so switching tabs keeps the box exactly where you put
* it, while a split zone beside them keeps its own popping out on the left
* doesn't fling a composer out of the right.
*
* Secondary windows (the tiny Ctrl+Shift+N window, subagent watch windows) stay
* docked: a floating composer makes no sense in a scratch window.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
// The floating composer is a window-level singleton: only the main scope
// (not tiles) in a primary window may pop out.
const scope = useComposerScope()
const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)
const popoutAllowed = !isSecondaryWindow()
const groupId = usePaneGroup()
const zone = useStore(useMemo(() => $composerPopoutZone(groupId), [groupId]))
const poppedOut = zone.poppedOut && popoutAllowed
const handleComposerPopOut = useCallback(() => {
triggerHaptic('open')
setComposerPoppedOut(true)
}, [])
setComposerPoppedOut(groupId, true)
}, [groupId])
const handleComposerDock = useCallback(() => {
triggerHaptic('success')
setComposerPoppedOut(false)
}, [])
setComposerPoppedOut(groupId, false)
}, [groupId])
// Double-click the grab area toggles dock/float. Undocking restores the last
// position (the persisted atom is never cleared on dock).
// position (a zone's stored position is never cleared on dock).
const handleComposerToggle = useCallback(() => {
poppedOut ? handleComposerDock() : handleComposerPopOut()
}, [handleComposerDock, handleComposerPopOut, poppedOut])
@ -56,39 +148,14 @@ export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
onPointerDown: onComposerGesturePointerDown
} = useComposerPopoutGestures({
composerRef,
groupId,
onDock: handleComposerDock,
onPopOut: handleComposerPopOut,
poppedOut,
position: popoutPosition
position: zone.position
})
// Keep the floating box on-screen: re-clamp (with the real measured size +
// thread bounds) when it pops out and on every window resize — so a position
// persisted on a bigger/other monitor, a shrunk window, or now-wider sidebar
// can never strand it. The rAF pass re-clamps after layout settles (sidebar
// widths, fonts), so anyone loading in out of bounds is pulled back + saved
// even if the first measure was premature.
useEffect(() => {
if (!poppedOut) {
return undefined
}
const reclamp = (persist: boolean) => {
const el = composerRef.current
const size = el ? { height: el.offsetHeight, width: el.offsetWidth } : undefined
setComposerPopoutPosition($composerPopoutPosition.get(), { area: readPopoutBounds(el), persist, size })
}
reclamp(true)
const raf = requestAnimationFrame(() => reclamp(true))
const onResize = () => reclamp(false)
window.addEventListener('resize', onResize)
return () => {
cancelAnimationFrame(raf)
window.removeEventListener('resize', onResize)
}
}, [composerRef, poppedOut])
const popoutPosition = usePopoutPlacement(composerRef, groupId, zone.position, dragging, poppedOut)
return {
dockProximity,

View file

@ -106,7 +106,9 @@ export function useComposerQueue({
entryId: entry.id,
sessionKey: activeQueueSessionKey
})
loadIntoComposer(entry.text, entry.attachments)
// Edit what the panel SHOWS. A queued `/skill` entry's text is the
// expanded skill body — never drop that into the composer.
loadIntoComposer(entry.displayText ?? entry.text, entry.attachments)
triggerHaptic('selection')
focusInput()
}
@ -135,7 +137,7 @@ export function useComposerQueue({
if (next) {
setQueueEditSnapshot({ ...queueEdit, entryId: next.id })
loadIntoComposer(next.text, next.attachments)
loadIntoComposer(next.displayText ?? next.text, next.attachments)
} else {
setQueueEditSnapshot(null)
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
@ -213,6 +215,7 @@ export function useComposerQueue({
const accepted = await Promise.resolve(
onSubmit(entry.text, {
attachments: entry.attachments,
...(entry.displayText ? { displayText: entry.displayText } : {}),
fromQueue: true,
sessionId: drainRuntimeSessionId,
storedSessionId: drainQueueSessionKey

View file

@ -1,7 +1,9 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $clarifyRequests } from '@/store/clarify'
import type { ComposerAttachment } from '@/store/composer'
import { $gateway } from '@/store/gateway'
import { useComposerSubmit } from './use-composer-submit'
@ -184,3 +186,80 @@ describe('useComposerSubmit busy-turn routing', () => {
)
})
})
describe('useComposerSubmit with a clarify parked on the session', () => {
const gatewayRequest = vi.fn(async () => ({ ok: true }))
const parkClarify = (sessionId: string) => {
$clarifyRequests.set({
[sessionId]: { requestId: `req-${sessionId}`, question: 'which one?', choices: ['a', 'b'], sessionId }
})
$gateway.set({ request: gatewayRequest } as unknown as ReturnType<typeof $gateway.get>)
}
afterEach(() => {
cleanup()
gatewayRequest.mockClear()
$clarifyRequests.set({})
$gateway.set(null)
vi.restoreAllMocks()
})
it('skips the question and still sends the typed message on an idle session', async () => {
parkClarify('runtime-session')
const { hook, onSubmit } = renderSubmitHook({ text: 'actually do this instead' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() =>
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', {
request_id: 'req-runtime-session',
answer: ''
})
)
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith('actually do this instead', expect.objectContaining({ attachments: [] }))
)
expect($clarifyRequests.get()['runtime-session']).toBeUndefined()
})
it('skips the question before steering a busy turn', async () => {
parkClarify('runtime-session')
const { hook, onSteer } = renderSubmitHook({ busy: true, text: 'change course' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(gatewayRequest).toHaveBeenCalledWith('clarify.respond', { request_id: 'req-runtime-session', answer: '' })
})
it('leaves the question alone for an empty Enter (Stop, not an answer)', () => {
parkClarify('runtime-session')
const { hook, onCancel } = renderSubmitHook({ busy: true })
act(() => {
hook.result.current.submitDraft()
})
expect(gatewayRequest).not.toHaveBeenCalled()
expect($clarifyRequests.get()['runtime-session']).toBeDefined()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it("leaves another session's question alone", async () => {
parkClarify('other-session')
const { hook, onSubmit } = renderSubmitHook({ text: 'unrelated message' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
expect(gatewayRequest).not.toHaveBeenCalled()
expect($clarifyRequests.get()['other-session']).toBeDefined()
})
})

View file

@ -2,12 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { hasClarifyRequest, skipClarifyRequest } from '@/store/clarify'
import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
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 type { ChatBarProps } from '../types'
@ -138,9 +140,27 @@ export function useComposerSubmit({
}
}
const text = draftRef.current
// A path that never got its committing space (`@apps/desktop/` left by a Tab
// descend, then Enter) is still the reference the user picked — promote it
// on the way out so it attaches instead of submitting as inert text.
const text = pathifyRefs(draftRef.current)
const payloadPresent = text.trim().length > 0 || attachments.length > 0
// A clarify card parked on this session owns the turn: the agent is blocked
// inside its tool batch waiting on `clarify.respond`, so a follow-up routed
// through steer/queue sits undelivered until the clarify's own timeout
// (default 5 min) — the message looks sent and nothing happens. Typing a
// real message instead of picking an option IS the answer "none of these":
// skip the question so the tool returns, then route the words normally.
//
// Fire-and-forget, not awaited: the skip clears the card synchronously and
// both RPCs ride the same socket in call order, so the gateway resolves the
// clarify before it sees the follow-up. Awaiting first would leave the draft
// live for a tick — long enough for a second Enter to send it twice.
if (payloadPresent && !queueEdit && hasClarifyRequest(sessionId)) {
void skipClarifyRequest(sessionId)
}
if (queueEdit) {
exitQueuedEdit('save')
} else if (busy) {

View file

@ -120,4 +120,106 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => {
expect(hook.result.current.trigger).toBeNull()
})
it('opens the list for a second slash after a leading command', () => {
// `/work /cle`: the command regex's argument tail would otherwise swallow
// `/cle` as an argument to `/work`, and a no-arg command suppresses the
// popover — so every slash after the first went dead.
const editor = mountEditor('/work /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' })
expect(hook.result.current.triggerItems).toHaveLength(1)
})
it('inserts the second command without disturbing the first', () => {
const editor = mountEditor('/work rewrite the composer /cle')
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ')
})
})
describe('useComposerTrigger — free-text slash arguments', () => {
it('keeps a picked /goal command as editable text while retaining subcommand completion', () => {
const editor = mountEditor('/go')
const goal = item('/goal', 'Commands')
const { hook } = mountTrigger(editor, [goal])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(goal))
expect(composerPlainText(editor)).toBe('/goal ')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(hook.result.current.trigger).not.toBeNull()
})
it('does not seal a multi-word /goal into a chip when the option list runs empty', () => {
const editor = mountEditor('/goal finish the full prompt')
const { hook } = mountTrigger(editor, [])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
expect(composerPlainText(editor)).toBe('/goal finish the full prompt')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
})
it('treats the default highlight as a suggestion until the user arrows to a row', () => {
const editor = mountEditor('/goal stat')
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerActiveExplicit).toBe(false)
act(() => hook.result.current.moveTriggerActive(1))
expect(hook.result.current.triggerActiveExplicit).toBe(true)
})
it('drops a deliberate selection once the query moves on', () => {
const editor = mountEditor('/goal stat')
const { hook } = mountTrigger(editor, [item('/goal status', 'Options')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.moveTriggerActive(1))
renderComposerContents(editor, '/goal start the migration')
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.triggerActiveExplicit).toBe(false)
})
it('keeps a multi-word /resume search typeable instead of firing the picker action', () => {
// The session list always ends in a "Browse all sessions…" action row, so
// an accept here doesn't insert a chip — it empties the composer and opens
// the overlay, taking the half-typed query with it.
const editor = mountEditor('/resume my new')
const { hook } = mountTrigger(editor, [item('/resume', 'Sessions')])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(true)
expect(hook.result.current.commitTypedSlashDirective()).toBe(false)
expect(composerPlainText(editor)).toBe('/resume my new')
})
it('still commits a fully typed finite option as one directive chip', () => {
const editor = mountEditor('/personality creative')
const { hook } = mountTrigger(editor, [])
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.slashFreeTextArgStage).toBe(false)
act(() => {
expect(hook.result.current.commitTypedSlashDirective()).toBe(true)
})
expect(composerPlainText(editor)).toBe('/personality creative ')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
})
})

View file

@ -2,7 +2,7 @@ import type { Unstable_TriggerAdapter, Unstable_TriggerItem } from '@assistant-u
import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { desktopSlashCommandArgumentMode } from '@/lib/desktop-slash-commands'
import {
COMPLETION_ACTIONS,
@ -53,6 +53,11 @@ export function useComposerTrigger({
}: UseComposerTriggerOptions) {
const [trigger, setTrigger] = useState<TriggerState | null>(null)
const [triggerActive, setTriggerActive] = useState(0)
// The list highlights its first row on open, which is a suggestion rather
// than a choice. This records that the user moved the highlight themselves,
// which is what lets Enter accept a completion in a free-text argument stage
// without stealing prose from everyone who never touched the arrows.
const [triggerActiveExplicit, setTriggerActiveExplicit] = useState(false)
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
// Set synchronously in keydown when the open trigger popover consumes a
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
@ -63,6 +68,11 @@ export function useComposerTrigger({
// re-rendered and the handler closure sees the post-keydown state.
const triggerKeyConsumedRef = useRef(false)
const resetTriggerActive = useCallback(() => {
setTriggerActive(0)
setTriggerActiveExplicit(false)
}, [])
const refreshTrigger = useCallback(() => {
const editor = editorRef.current
@ -80,7 +90,7 @@ export function useComposerTrigger({
if (!rawText.includes('@') && !rawText.includes('/')) {
if (trigger) {
setTrigger(null)
setTriggerActive(0)
resetTriggerActive()
}
return
@ -89,11 +99,16 @@ export function useComposerTrigger({
const before = textBeforeCaret(editor)
const found = detectTrigger(before ?? composerPlainText(editor))
// The arg-stage popover is only useful for commands with an options screen.
// For a no-arg command it would dead-end on "No matches", so drop it — the
// directive is already complete.
// A text-only command has no completion screen once its prose begins. Mixed
// commands such as /goal stay live so their finite subcommands can still be
// suggested, while arbitrary goal text remains valid.
const argumentMode =
found?.kind === '/' && slashArgStage(found.query)
? desktopSlashCommandArgumentMode(slashCommandToken(found.query))
: null
const detected =
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
found?.kind === '/' && slashArgStage(found.query) && argumentMode !== 'options' && argumentMode !== 'mixed'
? null
: found
@ -104,9 +119,9 @@ export function useComposerTrigger({
// caret move (mouseup) or a stray refresh — must preserve the user's
// current selection instead of snapping back to the first item.
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
setTriggerActive(0)
resetTriggerActive()
}
}, [editorRef, trigger])
}, [editorRef, resetTriggerActive, trigger])
const triggerAdapter: Unstable_TriggerAdapter | null =
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
@ -134,10 +149,23 @@ export function useComposerTrigger({
// Space/Tab — neither should dead-end on a popover.
const argStageEmpty = trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
const slashArgumentMode =
trigger?.kind === '/' && slashArgStage(trigger.query)
? desktopSlashCommandArgumentMode(slashCommandToken(trigger.query))
: null
const slashFreeTextArgStage = slashArgumentMode === 'mixed' || slashArgumentMode === 'text'
const closeTrigger = () => {
setTrigger(null)
setTriggerItems([])
setTriggerActive(0)
resetTriggerActive()
}
/** Step the highlight, marking it as the user's own deliberate pick. */
const moveTriggerActive = (delta: number) => {
setTriggerActiveExplicit(true)
setTriggerActive(idx => (idx + delta + triggerItems.length) % triggerItems.length)
}
useEffect(() => {
@ -148,9 +176,16 @@ export function useComposerTrigger({
// the completion list is empty because the arg is already fully typed (the
// backend completer drops exact matches). Reuses the chip path via a
// synthetic item whose serialized form is the verbatim text.
const commitTypedSlashDirective = () => {
const commitTypedSlashDirective = (): boolean => {
if (trigger?.kind !== '/') {
return
return false
}
// Free prose must stay ordinary contentEditable text. This guard also
// protects against a stale completion result reaching the keydown path
// before refreshTrigger has caught up with the latest DOM input.
if (desktopSlashCommandArgumentMode(slashCommandToken(trigger.query)) !== 'options') {
return false
}
const text = `/${trigger.query.trimEnd()}`
@ -168,9 +203,11 @@ export function useComposerTrigger({
rawText: text
}
})
return true
}
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
const replaceTriggerWithChip = (item: Unstable_TriggerItem, options?: { descend?: boolean }) => {
const editor = editorRef.current
if (!editor || !trigger) {
@ -200,6 +237,31 @@ export function useComposerTrigger({
const serialized = hermesDirectiveFormatter.serialize(item)
const starter = serialized.endsWith(':')
// Tab on a folder walks INTO it instead of committing it: re-type the
// token as the bare path so the next `complete.path` lists that folder's
// children, exactly as typing the path by hand would. Enter still commits
// the folder itself — the two intents are distinct, so the keys are too.
// Only `@` folders descend; a slash command's arg list has no hierarchy.
const descendInto =
options?.descend && trigger.kind === '@' && item.type === 'folder'
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
: ''
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${path}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
window.setTimeout(refreshTrigger, 0)
return
}
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
// it — expand to its options step so the popover shows the inline list, just
// as typing `/personality ` by hand would. A serialized value with a space is
@ -208,15 +270,15 @@ export function useComposerTrigger({
// there's no command invocation for the args to belong to.
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
const expandsToArgs =
trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
const argumentMode = desktopSlashCommandArgumentMode(command)
const expandsToArgs = trigger.kind === '/' && !trigger.inline && !serialized.includes(' ') && argumentMode !== null
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
// No pill while expanding — the bare command stays plain text until an arg
// is picked, at which point a single pill is emitted for the full command.
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || expandsToArgs
const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text')
const finish = () => {
draftRef.current = composerPlainText(editor)
@ -281,15 +343,47 @@ export function useComposerTrigger({
finish()
}
/** Backspace inside an `@` path drops the last segment (`a/b/` `a/`)
* instead of one character. Descending is one Tab per level, so climbing
* back out should cost one key too rather than a held delete. Returns
* false when the caret isn't in a path, so keydown falls through. */
const ascendTriggerPath = () => {
const editor = editorRef.current
if (!editor || trigger?.kind !== '@' || !trigger.query.includes('/')) {
return false
}
// Trailing slash means we're listing a folder's children: drop that
// folder. Otherwise a partial segment is typed — drop just that.
const trimmed = trigger.query.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${parent}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
window.setTimeout(refreshTrigger, 0)
return true
}
return {
argStageEmpty,
ascendTriggerPath,
closeTrigger,
commitTypedSlashDirective,
moveTriggerActive,
refreshTrigger,
replaceTriggerWithChip,
setTriggerActive,
slashFreeTextArgStage,
trigger,
triggerActive,
triggerActiveExplicit,
triggerItems,
triggerKeyConsumedRef,
triggerLoading

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