Merge origin/main into feat/hermes-relay-shared-metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier 2026-07-27 08:12:37 -07:00
commit 4fe4b0dca7
192 changed files with 10055 additions and 594 deletions

View file

@ -1952,8 +1952,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

@ -4381,6 +4381,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.
@ -4389,8 +4390,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.
@ -4407,9 +4423,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)
@ -4519,6 +4549,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.
@ -4526,6 +4557,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.
"""
@ -4537,7 +4587,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)
@ -4545,9 +4612,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})"
@ -8355,6 +8433,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
@ -8363,7 +8450,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)
@ -8372,10 +8460,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(
@ -8939,6 +9029,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
@ -8947,7 +9046,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)
@ -8956,10 +9056,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

@ -440,26 +440,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.
@ -470,7 +500,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:
@ -1521,45 +1551,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()
@ -1645,33 +1636,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)

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

View file

@ -5497,6 +5497,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:
@ -6328,7 +6336,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

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

@ -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

@ -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

@ -703,8 +703,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 = (
f"'{_underlying}' is not available in this session. "
@ -1353,8 +1360,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. "

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

@ -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,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,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,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

@ -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

@ -1,6 +1,7 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $artifactTabs } from '@/store/artifacts'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
@ -32,7 +33,7 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
// 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) {
if ($previewTarget.get() || $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0) {
return closeActiveRightRailTab()
}

View file

@ -71,7 +71,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onCancel: () => Promise<void> | void
onAddContextRef: (refText: string, label?: string, detail?: string) => void
onAddUrl: (url: string) => void
onBranchInNewChat: (messageId: string) => void
onBranchInNewChat?: (messageId: string) => void
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise<boolean | void> | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise<boolean | void> | boolean | void

View file

@ -0,0 +1,222 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { artifactDownloadName } from '@/lib/artifact-detect'
import { downloadTextFile } from '@/lib/download-text'
import { ChevronLeft, ChevronRight, Download, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$artifactRegistry,
$artifactVersionSelection,
type ArtifactRecord,
selectArtifactVersion
} from '@/store/artifacts'
import { notifyError } from '@/store/notifications'
import { ArtifactLivePreview, ArtifactSourceView, composeArtifactHtml } from './artifact-renderers'
import { PreviewEmptyState } from './preview-file'
type ArtifactViewMode = 'preview' | 'source'
const MIME_BY_KIND = { code: 'text/plain', html: 'text/html', svg: 'image/svg+xml' } as const
const HEADER_BUTTON_CLASS =
'flex h-5 items-center gap-1 rounded-md px-1 text-[0.625rem] font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
/** Write the composed document to a real temp file through the existing
* buffer-save IPC, then hand it to the OS browser. A blob/data URL can't
* cross into the OS default browser, so a file on disk is the honest path. */
async function openHtmlInBrowser(content: string): Promise<void> {
const bridge = window.hermesDesktop
if (!bridge?.saveImageBuffer || !bridge.openExternal) {
throw new Error('Desktop bridge unavailable')
}
const bytes = new TextEncoder().encode(composeArtifactHtml(content))
const path = await bridge.saveImageBuffer(bytes, '.html')
if (!path) {
throw new Error('Could not write artifact file')
}
const fileUrl = `file://${path.startsWith('/') ? '' : '/'}${path.replace(/\\/g, '/')}`
if (bridge.openPreviewInBrowser) {
await bridge.openPreviewInBrowser(fileUrl)
return
}
await bridge.openExternal(fileUrl)
}
function VersionStepper({
current,
onSelect,
total
}: {
current: number
onSelect: (index: number) => void
total: number
}) {
const { t } = useI18n()
const copy = t.artifactPane
if (total < 2) {
return null
}
return (
<div className="flex items-center gap-0.5 text-[0.625rem] font-bold text-muted-foreground">
<Tip label={copy.olderVersion}>
<button
aria-label={copy.olderVersion}
className={HEADER_BUTTON_CLASS}
disabled={current === 0}
onClick={() => onSelect(current - 1)}
type="button"
>
<ChevronLeft className="size-3" />
</button>
</Tip>
<span className="tabular-nums">{copy.versionOf(current + 1, total)}</span>
<Tip label={copy.newerVersion}>
<button
aria-label={copy.newerVersion}
className={HEADER_BUTTON_CLASS}
disabled={current === total - 1}
onClick={() => onSelect(current + 1)}
type="button"
>
<ChevronRight className="size-3" />
</button>
</Tip>
</div>
)
}
export function ArtifactPane({ artifactId }: { artifactId: string }) {
const { t } = useI18n()
const copy = t.artifactPane
const registry = useStore($artifactRegistry)
const versionSelection = useStore($artifactVersionSelection)
// View mode is per-pane, ephemeral: renderable artifacts open in preview.
const [userMode, setUserMode] = useState<ArtifactViewMode | null>(null)
// Reset the explicit mode when the pane is reused for another artifact.
useEffect(() => {
setUserMode(null)
}, [artifactId])
const record = useMemo<ArtifactRecord | null>(() => {
for (const records of Object.values(registry)) {
const found = records.find(candidate => candidate.id === artifactId)
if (found) {
return found
}
}
return null
}, [artifactId, registry])
if (!record) {
return <PreviewEmptyState body={copy.missingBody} title={copy.missingTitle} />
}
const isRenderable = record.kind === 'html' || record.kind === 'svg'
const versionIndex = Math.min(versionSelection[artifactId] ?? record.versions.length - 1, record.versions.length - 1)
const version = record.versions[versionIndex]!
const isCurrentVersion = versionIndex >= record.versions.length - 1
const mode: ArtifactViewMode = isRenderable ? (userMode ?? 'preview') : 'source'
const downloadName = artifactDownloadName(record.kind, record.language, record.title)
const modeLabel: Record<ArtifactViewMode, string> = {
preview: copy.modePreview,
source: copy.modeSource
}
return (
<div className="flex h-full flex-col overflow-hidden bg-transparent">
<div className="flex h-7 shrink-0 items-center gap-3 border-b border-border/40 px-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<VersionStepper
current={versionIndex}
onSelect={index => selectArtifactVersion(artifactId, index)}
total={record.versions.length}
/>
{!isCurrentVersion && (
<button
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/25 underline-offset-4 transition-colors hover:text-foreground"
onClick={() => selectArtifactVersion(artifactId, record.versions.length - 1)}
type="button"
>
{copy.latest}
</button>
)}
</div>
{isRenderable &&
(['preview', 'source'] as const).map(candidate => (
<button
className={cn(
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
candidate === mode
? 'text-foreground underline decoration-current/30'
: 'text-muted-foreground hover:text-foreground'
)}
key={candidate}
onClick={() => setUserMode(candidate)}
type="button"
>
{modeLabel[candidate]}
</button>
))}
<div className="flex items-center gap-0.5">
<CopyButton
appearance="inline"
className="h-5 px-1 opacity-70 hover:opacity-100"
iconClassName="size-3"
label={copy.copyContent}
showLabel={false}
text={version.content}
/>
<Tip label={copy.download}>
<button
aria-label={copy.download}
className={HEADER_BUTTON_CLASS}
onClick={() => downloadTextFile(downloadName, version.content, MIME_BY_KIND[record.kind])}
type="button"
>
<Download className="size-3" />
</button>
</Tip>
{record.kind === 'html' && window.hermesDesktop && (
<Tip label={copy.openInBrowser}>
<button
aria-label={copy.openInBrowser}
className={HEADER_BUTTON_CLASS}
onClick={() =>
void openHtmlInBrowser(version.content).catch(error => notifyError(error, copy.openInBrowserFailed))
}
type="button"
>
<ExternalLink className="size-3" />
</button>
</Tip>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{mode === 'preview' && isRenderable ? (
<ArtifactLivePreview content={version.content} kind={record.kind} title={record.title} />
) : (
<ArtifactSourceView language={record.kind === 'html' ? 'html' : record.language} text={version.content} />
)}
</div>
</div>
)
}

View file

@ -0,0 +1,107 @@
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
import ShikiHighlighter from 'react-shiki'
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
import type { ArtifactKind } from '@/lib/artifact-detect'
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
const SOURCE_CHUNK_LINES = 200
const SOURCE_LINE_PX = 20
const SOURCE_OVERSCAN_LINES = 400
/** Windowed, Shiki-highlighted source view for artifact content. Same fixed-row
* windowing as the file preview's SourceView so a 5k-line artifact scrolls
* smoothly, minus the gutter drag/selection machinery (artifact content has no
* on-disk path to reference lines against). */
export function ArtifactSourceView({ language, text }: { language: string; text: string }) {
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
const lastChunk = chunks.at(-1)
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
overscanRows: SOURCE_OVERSCAN_LINES,
rowPx: SOURCE_LINE_PX,
rowsPerChunk: SOURCE_CHUNK_LINES,
totalRows: totalLines
})
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
return (
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
<div className="min-w-max px-3 py-2 font-mono text-[0.7rem] leading-relaxed" data-selectable-text="true">
{beforeRows > 0 && <div aria-hidden style={{ height: beforeRows * SOURCE_LINE_PX }} />}
{visibleChunks.map(chunk => (
<div className="[&_pre]:m-0" key={chunk.start}>
<ShikiHighlighter
addDefaultStyles={false}
as="div"
defaultColor="light-dark()"
delay={80}
language={language || 'text'}
showLanguage={false}
theme={SHIKI_THEME}
>
{chunk.text}
</ShikiHighlighter>
</div>
))}
{afterRows > 0 && <div aria-hidden style={{ height: afterRows * SOURCE_LINE_PX }} />}
</div>
</div>
)
}
/** Wrap an HTML fragment in a minimal document shell; full documents pass
* through untouched. Keeps generated fragments (no <html>/<body>) rendering
* with sane defaults instead of quirks-mode soup. */
export function composeArtifactHtml(content: string): string {
if (/<html[\s>]|<!doctype\s+html/i.test(content)) {
return content
}
return [
'<!doctype html>',
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<style>body{margin:0;font-family:system-ui,sans-serif}</style></head><body>',
content,
'</body></html>'
].join('\n')
}
/**
* Sandboxed live renderer for html/svg artifact content.
*
* HTML runs in an `<iframe sandbox="allow-scripts">` scripts execute in an
* opaque origin with no same-origin access, no top navigation, no popups, no
* form submission out of the frame. The parent app is unreachable. SVG is
* DOMPurify-sanitized with the same profile as the inline ```svg embed.
*/
export function ArtifactLivePreview({ content, kind, title }: { content: string; kind: ArtifactKind; title: string }) {
const svgClean = useMemo(
() => (kind === 'svg' ? DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } }) : ''),
[content, kind]
)
if (kind === 'svg') {
return (
<div className="grid h-full place-items-center overflow-auto bg-background p-4 [&_svg]:h-auto [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:max-w-full">
<div dangerouslySetInnerHTML={{ __html: svgClean }} />
</div>
)
}
return (
<iframe
className="block size-full border-0 bg-white"
sandbox="allow-scripts"
srcDoc={composeArtifactHtml(content)}
// Deliberately raw white + forced light scheme: the frame hosts foreign
// generated HTML that assumes a light canvas, so it renders deterministically
// light in both app themes instead of inheriting theme tokens it can't see.
style={{ colorScheme: 'light' }}
title={title}
/>
)
}

View file

@ -15,6 +15,7 @@ import { Tip } from '@/components/ui/tooltip'
import { translateNow, useI18n } from '@/i18n'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $openArtifacts, artifactIdFromTabId } from '@/store/artifacts'
import {
$panesFlipped,
$rightRailActiveTabId,
@ -34,6 +35,7 @@ import {
} from '@/store/preview'
import { $dirtyPreviewUrls } from '@/store/preview-edit'
import { ArtifactPane } from './artifact-pane'
import { PreviewPane } from './preview-pane'
export const PREVIEW_RAIL_MIN_WIDTH = '18rem'
@ -44,11 +46,9 @@ interface ChatPreviewRailProps {
setTitlebarToolGroup?: SetTitlebarToolGroup
}
interface RailTab {
id: RightRailTabId
label: string
target: PreviewTarget
}
type RailTab =
| { id: RightRailTabId; kind: 'preview'; label: string; target: PreviewTarget; tooltip: string }
| { artifactId: string; id: RightRailTabId; kind: 'artifact'; label: string; tooltip: string }
function tabLabelFor(target: PreviewTarget): string {
const value = target.label || target.path || target.source || target.url
@ -65,15 +65,43 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const filePreviewTabs = useStore($filePreviewTabs)
const previewTarget = useStore($previewTarget)
const dirtyPreviewUrls = useStore($dirtyPreviewUrls)
const openArtifacts = useStore($openArtifacts)
const tabs = useMemo<readonly RailTab[]>(
() => [
...(previewTarget
? [{ id: RIGHT_RAIL_PREVIEW_TAB_ID, label: t.preview.tab, target: previewTarget } as RailTab]
? [
{
id: RIGHT_RAIL_PREVIEW_TAB_ID,
kind: 'preview',
label: t.preview.tab,
target: previewTarget,
tooltip: previewTarget.path || previewTarget.url || t.preview.tab
} as RailTab
]
: []),
...filePreviewTabs.map(({ id, target }) => ({ id, label: tabLabelFor(target), target }) as RailTab)
...filePreviewTabs.map(
({ id, target }) =>
({
id,
kind: 'preview',
label: tabLabelFor(target),
target,
tooltip: target.path || target.url || tabLabelFor(target)
}) as RailTab
),
...openArtifacts.map(
({ record, tabId }) =>
({
artifactId: record.id,
id: tabId,
kind: 'artifact',
label: record.title || t.artifactPane.tabFallback,
tooltip: record.title || t.artifactPane.tabFallback
}) as RailTab
)
],
[filePreviewTabs, previewTarget, t.preview.tab]
[filePreviewTabs, openArtifacts, previewTarget, t.artifactPane.tabFallback, t.preview.tab]
)
const activeTab = tabs.find(tab => tab.id === activeTabId) ?? tabs[0]
@ -117,13 +145,13 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
const active = tab.id === activeTab.id
const hasOthers = tabs.length > 1
const hasTabsToRight = index < tabs.length - 1
const dirty = Boolean(dirtyPreviewUrls[tab.target.url])
const dirty = tab.kind === 'preview' && Boolean(dirtyPreviewUrls[tab.target.url])
return (
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<PaneTab active={active} dirty={dirty} onClose={() => closeRightRailTab(tab.id)}>
<Tip label={tab.target.path || tab.target.url || tab.label}>
<Tip label={tab.tooltip}>
<PaneTabLabel
aria-selected={active}
as="button"
@ -132,6 +160,9 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
role="tab"
type="button"
>
{tab.kind === 'artifact' && (
<Codicon className="mr-1 shrink-0 text-[0.6875rem] opacity-70" name="sparkle" />
)}
{tab.label}
</PaneTabLabel>
</Tip>
@ -166,13 +197,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<PreviewPane
embedded
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
{activeTab.kind === 'artifact' ? (
<ArtifactPane artifactId={artifactIdFromTabId(activeTab.id) ?? activeTab.artifactId} />
) : (
<PreviewPane
embedded
onRestartServer={isPreview ? onRestartServer : undefined}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={activeTab.target}
/>
)}
</div>
</aside>
)

View file

@ -170,7 +170,6 @@ function TileChat({
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}
onAttachImageBlob={composer.attachImageBlob}
onBranchInNewChat={() => undefined}
onCancel={actions.cancelRun}
onDeleteSelectedSession={() => undefined}
onDismissError={actions.dismissError}

View file

@ -205,7 +205,16 @@ export function SidebarSessionsSection({
// The flat recents/pinned list is the only place sessions reorder by hand;
// grouped/tree views always sort by creation date and never drag.
const sessionsDraggable = sortable && !!onReorderSessions
const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions])
// Pinned and manual-drag lists pass sessions already in the caller's order.
// Default recents stay dateGrouped and still re-sort roots by group recency
// so partition buckets stay truthful — but NEVER for pins, where a turn
// finishing was floating background tasks over the user's fixed ranking.
const preserveInputOrder = pinned || (sessionsDraggable && !dateGrouped)
const displayEntries = useMemo(
() => flattenSessionsWithBranches(sessions, { preserveOrder: preserveInputOrder }),
[sessions, preserveInputOrder]
)
const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => {
const rowProps = {

View file

@ -39,6 +39,7 @@ import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { LayoutDashboard } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { $artifactTabs } from '@/store/artifacts'
import {
$fileBrowserOpen,
$panesFlipped,
@ -51,7 +52,7 @@ import {
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH
} from '@/store/layout'
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
@ -552,9 +553,10 @@ bindPaneCollapse(
// Preview EXISTS only while something is previewed (old-shell semantics:
// closing the last preview tab closes the pane; a new target opens + fronts
// it). Same visibility binding as every other self-managed surface, driven
// by the live targets instead of a toggle.
const $previewVisible = computed([$previewTarget, $filePreviewTarget], (target, fileTarget) =>
Boolean(target || fileTarget)
// by the live targets (and open artifact tabs) instead of a toggle.
const $previewVisible = computed(
[$previewTarget, $filePreviewTabs, $artifactTabs],
(target, fileTabs, artifactTabs) => Boolean(target) || fileTabs.length > 0 || artifactTabs.length > 0
)
bindPaneVisibility('preview', $previewVisible, closeRightRail)
@ -603,6 +605,17 @@ const revealPreview = () => {
$previewTarget.listen(target => target && revealPreview())
$filePreviewTarget.listen(target => target && revealPreview())
// Artifact reveal keys on tab OPENS (length grows), not list identity — closing
// one of two artifact tabs must not re-front the pane.
let lastArtifactTabCount = $artifactTabs.get().length
$artifactTabs.listen(tabs => {
const grew = tabs.length > lastArtifactTabCount
lastArtifactTabCount = tabs.length
if (grew) {
revealPreview()
}
})
// ---------------------------------------------------------------------------

View file

@ -34,7 +34,7 @@ export function latestChatActions(actions: ChatActions): ChatActions {
onAddUrl: (...args) => actions.onAddUrl(...args),
onAttachDroppedItems: (...args) => actions.onAttachDroppedItems(...args),
onAttachImageBlob: (...args) => actions.onAttachImageBlob(...args),
onBranchInNewChat: (...args) => actions.onBranchInNewChat(...args),
onBranchInNewChat: latestOptional(() => actions.onBranchInNewChat),
onCancel: (...args) => actions.onCancel(...args),
onDeleteSelectedSession: (...args) => actions.onDeleteSelectedSession(...args),
onDismissError: latestOptional(() => actions.onDismissError),

View file

@ -27,6 +27,7 @@ import { registry } from '@/contrib/registry'
import { getLogs } from '@/hermes'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $openArtifacts } from '@/store/artifacts'
import { $filePreviewTarget, $previewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentCwd } from '@/store/session'
@ -74,9 +75,10 @@ export const $restartPreviewServer = atom<((url: string, context?: string) => Pr
export function PreviewRailPane() {
const previewTarget = useStore($previewTarget)
const fileTarget = useStore($filePreviewTarget)
const openArtifacts = useStore($openArtifacts)
const restartPreviewServer = useStore($restartPreviewServer)
if (!previewTarget && !fileTarget) {
if (!previewTarget && !fileTarget && openArtifacts.length === 0) {
return (
<div className="grid h-full place-items-center px-4 text-center">
<div className="flex flex-col items-center gap-1.5">

View file

@ -47,7 +47,7 @@ import {
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { pruneDelegateFallbackSubagents, pruneFinishedSessionSubagents, upsertSubagent } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { reportInstallMethodWarning } from '@/store/updates'
@ -439,7 +439,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
flushQueuedDeltas(sessionId)
clearSessionSubagents(sessionId)
pruneFinishedSessionSubagents(sessionId)
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)

View file

@ -29,7 +29,7 @@ import { setSessionTodos } from '@/store/todos'
import type { ClientSessionState } from '../../../types'
import { useGatewayEventHandler } from './gateway-event'
import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils'
import { completionErrorText, delegateTaskPayloads, MAX_STREAM_FLUSH_GAP_MS, STREAM_DELTA_FLUSH_MS } from './utils'
interface MessageStreamOptions {
activeGatewayProfile?: string
@ -184,6 +184,9 @@ export function useMessageStream({
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
// What the previous flush cost on the main thread — drives the adaptive
// flush floor in scheduleDeltaFlush so multi-stream load yields to input.
const lastFlushCostRef = useRef<number>(0)
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
const compactedTurnRef = useRef<Set<string>>(new Set())
@ -243,12 +246,29 @@ export function useMessageStream({
// length. With this floor, slower streams still coalesce ~2 tokens per
// commit and the synthetic harness shows longtask counts drop from ~5/5s
// to ~1/5s on big sessions (see scripts/profile-typing-lag.md).
//
// ADAPTIVE: the floor scales with what the last flush actually cost.
// With several sessions streaming at once (split tiles), one flush carries
// every stream's commit + markdown re-parse; when that work approaches or
// exceeds the fixed 33ms budget, back-to-back flushes leave the main
// thread no idle frames and every interaction (typing, resize, hover)
// stutters even though no render is wasted. Yielding 3x the measured cost
// keeps the thread ~75% idle for input at any load: cheap flushes stay at
// 30fps of text growth, expensive multi-stream flushes degrade text fps
// instead of interactivity — capped so text never updates slower than 4/s.
const sinceLast = performance.now() - lastFlushAtRef.current
const adaptiveFloor = Math.min(
Math.max(STREAM_DELTA_FLUSH_MS, lastFlushCostRef.current * 3),
MAX_STREAM_FLUSH_GAP_MS
)
const runFlush = () => {
flushHandleRef.current = null
lastFlushAtRef.current = performance.now()
const startedAt = performance.now()
lastFlushAtRef.current = startedAt
flushQueuedDeltas()
lastFlushCostRef.current = performance.now() - startedAt
}
// Always a timer, never requestAnimationFrame. Chromium pauses rAF for a
@ -265,7 +285,7 @@ export function useMessageStream({
// for) while guaranteeing delivery without user interaction. Timers are
// clamped in background renderers rather than suspended, and
// disable-background-timer-throttling already opts out of that clamp.
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast))
}, [flushQueuedDeltas])
const queueDelta = useCallback(

View file

@ -66,6 +66,12 @@ export function hasSessionInfoStatePatch(patch: SessionRuntimeStatePatch): boole
// `scripts/profile-typing-lag.md` for the measurement work behind this.
export const STREAM_DELTA_FLUSH_MS = 33
// Ceiling for the ADAPTIVE flush gap (see scheduleDeltaFlush). Under heavy
// multi-stream load the gap stretches to 3x the measured flush cost so the
// main thread stays responsive to input; this cap guarantees streaming text
// still visibly updates at least ~4x per second no matter the load.
export const MAX_STREAM_FLUSH_GAP_MS = 250
// Gateway/provider failures sometimes arrive as message.complete text instead
// of an explicit error event. Treat matches as inline assistant errors so they
// persist like real error events and don't get erased by hydrate fallback.

View file

@ -929,6 +929,51 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
vi.restoreAllMocks()
})
it('executes /approvals against the focused profile session and persists its mode', async () => {
const focusedProfile = 'work'
const focusedSessionId = 'work-runtime-session'
const persistedModes = new Map<string, string>()
const sessionProfiles = new Map([[focusedSessionId, focusedProfile]])
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'slash.exec') {
const sessionId = String(params?.session_id ?? '')
const profile = sessionProfiles.get(sessionId)
const command = String(params?.command ?? '')
if (profile && command === 'approvals off') {
persistedModes.set(profile, 'off')
}
return { output: 'Approval mode: off (persistent profile setting).' } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
activeSessionId={focusedSessionId}
activeSessionIdRef={{ current: focusedSessionId }}
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={focusedSessionId}
/>
)
await handle!.submitText('/approvals off')
expect(requestGateway).toHaveBeenCalledWith('slash.exec', {
command: 'approvals off',
session_id: focusedSessionId
})
expect(persistedModes.get(focusedProfile)).toBe('off')
expect(persistedModes.has('default')).toBe(false)
})
it('submits /goal send directives returned directly by slash.exec instead of rendering no output', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const states: Record<string, unknown>[] = []

View file

@ -977,19 +977,23 @@ describe('resumeSession failure recovery', () => {
})
function BranchHarness({
activeSessionId = null,
navigate = vi.fn(),
onCurrentReady,
onReady,
requestGateway
}: {
activeSessionId?: string | null
navigate?: ReturnType<typeof vi.fn>
onCurrentReady?: (branchCurrentSession: (messageId?: string) => Promise<boolean>) => void
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
const actions = useSessionActions({
activeSessionId: null,
activeSessionIdRef: ref<string | null>(null),
activeSessionId,
activeSessionIdRef: ref<string | null>(activeSessionId),
busyRef: ref(false),
creatingSessionRef: ref(false),
ensureSessionState: () => ({}) as ClientSessionState,
@ -1008,7 +1012,8 @@ function BranchHarness({
useEffect(() => {
onReady(actions.branchStoredSession)
}, [actions.branchStoredSession, onReady])
onCurrentReady?.(actions.branchCurrentSession)
}, [actions.branchCurrentSession, actions.branchStoredSession, onCurrentReady, onReady])
return null
}
@ -1090,6 +1095,56 @@ describe('branchStoredSession desktop source tagging', () => {
})
})
it('branches an open live chat via session.branch with a trimmed message count (bug #1/#3 fix)', async () => {
let branchParams: Record<string, unknown> | undefined
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === 'session.branch') {
branchParams = params
return {
session_id: 'branch-runtime',
stored_session_id: 'branch-stored',
title: 'Branch',
message_count: 2,
messages: [],
info: {}
} as never
}
return {} as never
})
setMessages([
{ id: 'q1', role: 'user', parts: [{ type: 'text', text: 'question one' }] },
{ id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'answer one' }] },
{ id: 'q2', role: 'user', parts: [{ type: 'text', text: 'question two' }] },
{ id: 'a2', role: 'assistant', parts: [{ type: 'text', text: 'answer two' }] }
])
let branchCurrentSession: ((messageId?: string) => Promise<boolean>) | null = null
render(
<BranchHarness
activeSessionId="live-parent"
onCurrentReady={branch => (branchCurrentSession = branch)}
onReady={() => undefined}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(branchCurrentSession).not.toBeNull())
// Branch from the FIRST assistant reply ("a1"), not the last message <20>
// this is exactly the scenario that used to drop the question (bug #1):
// only the clicked message survived instead of everything up to it.
await expect(branchCurrentSession!('a1')).resolves.toBe(true)
expect(requestGateway).toHaveBeenCalledWith('session.branch', {
session_id: 'live-parent',
count: 2
})
expect(branchParams).toEqual({ session_id: 'live-parent', count: 2 })
})
// #67603: right-clicking a session outside the paginated sidebar window is a
// cache miss. Resolve its owning profile (cache → active → cross-profile) and
// swap to it before reading the transcript / creating the branch, so the fork

View file

@ -1102,6 +1102,7 @@ export function useSessionActions({
const forkBranch = useCallback(
async (
branchMessages: BranchMessage[],
sourceSessionId: null | string,
parentStoredId: null | string,
cwd?: string,
profile?: null | string
@ -1119,14 +1120,19 @@ export function useSessionActions({
await ensureGatewayProfile(profile)
// No title: the backend auto-names the branch from its parent's lineage.
const branched = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
})
const branched = sourceSessionId
? await requestGateway<SessionCreateResponse>('session.branch', {
session_id: sourceSessionId,
count: branchMessages.length
})
: await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
})
const routedSessionId = branched.stored_session_id ?? branched.session_id
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
@ -1212,7 +1218,7 @@ export function useSessionActions({
? messages.findIndex(message => message.id === messageId)
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
const start = at >= 0 ? at : Math.max(messages.length - 1, 0)
const start = 0
const end = at >= 0 ? at + 1 : messages.length
const branchMessages = toBranchMessages(messages.slice(start, end))
@ -1229,7 +1235,13 @@ export function useSessionActions({
// must stay on that thread's backend (cache hit for an open session).
const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current)
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile)
return forkBranch(
branchMessages,
activeSessionIdRef.current,
selectedStoredSessionIdRef.current,
$currentCwd.get().trim(),
profile
)
},
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
)
@ -1261,7 +1273,7 @@ export function useSessionActions({
return false
}
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
return await forkBranch(branchMessages, null, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile)
} catch (err) {
notifyError(err, copy.branchFailed)

View file

@ -493,6 +493,7 @@ export function useStatusbarItems({
id: 'running-timer',
label: copy.turnRunning,
title: copy.currentTurnElapsed,
toggleLabel: copy.toggleRunningTimer,
variant: 'text'
},
{
@ -511,6 +512,7 @@ export function useStatusbarItems({
/>
),
title: copy.openContextUsage,
toggleLabel: copy.toggleContextUsage,
variant: 'menu'
},
{
@ -519,6 +521,7 @@ export function useStatusbarItems({
id: 'session-timer',
label: copy.session,
title: copy.runtimeSessionElapsed,
toggleLabel: copy.toggleSessionTimer,
variant: 'text'
},
{

View file

@ -96,4 +96,25 @@ describe('statusbar item visibility', () => {
expect(screen.getByText('Plugin thing')).toBeTruthy()
})
it('starts the per-turn session readouts hidden and restores them from the menu', async () => {
const statusbar = bar([
item('running-timer', 'Turn timer', { variant: 'text' }),
item('context-usage', 'Context meter', { variant: 'menu' }),
item('session-timer', 'Session timer', { variant: 'text' }),
item('gateway-health', 'Gateway')
])
for (const label of ['Turn timer', 'Context meter', 'Session timer']) {
expect(screen.queryByText(label)).toBeNull()
}
openContextMenu(statusbar)
const row = await screen.findByRole('menuitemcheckbox', { name: 'Session timer' })
fireEvent.click(row)
expect($statusbarHiddenIds.get()).not.toContain('session-timer')
expect(within(statusbar).getByText('Session timer')).toBeTruthy()
})
})

View file

@ -0,0 +1,145 @@
'use client'
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { CodeCardIcon } from '@/components/chat/code-card'
import { useI18n } from '@/i18n'
import type { ArtifactDetection } from '@/lib/artifact-detect'
import { codiconForLanguage } from '@/lib/markdown-code'
import { cn } from '@/lib/utils'
import {
$artifactRegistry,
artifactsForSession,
openArtifactTab,
selectArtifactVersion,
upsertArtifact
} from '@/store/artifacts'
interface ArtifactCardProps {
code: string
detection: ArtifactDetection
streaming?: boolean
}
const KIND_ICON: Record<ArtifactDetection['kind'], string> = {
code: 'code',
html: 'browser',
svg: 'symbol-color'
}
function detectionIcon(detection: ArtifactDetection): string {
return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind]
}
/**
* Transcript stand-in for a fenced block that was promoted to an artifact.
* Replaces the wall of code with a compact, openable card: icon, title, kind,
* version badge. While the fence is still streaming
* it shows a shimmer + line count instead of the growing source.
*
* Registration is automatic on completion (so version history accumulates
* even if the user never opens the card) but opening the rail is strictly
* click-driven a background stream never steals the pane.
*/
export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) {
const { t } = useI18n()
const copy = t.artifactCard
const view = useSessionView()
const runtimeId = useStore(view.$runtimeId)
const storedId = useStore(view.$storedId)
const registry = useStore($artifactRegistry)
const sessionId = storedId || runtimeId || ''
const trimmed = code.trim()
// Register/version the artifact once its fence has finished streaming.
// upsertArtifact dedupes on content hash, so re-renders and transcript
// replays are no-ops.
useEffect(() => {
if (!streaming && sessionId && trimmed) {
upsertArtifact(sessionId, detection, trimmed)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code
}, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed])
const record = useMemo(() => {
void registry
const slugMatch = artifactsForSession(sessionId).find(
candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed)
)
return slugMatch ?? null
}, [detection.kind, registry, sessionId, trimmed])
const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed])
const kindLabel = copy.kind[detection.kind]
const versionCount = record?.versions.length ?? 0
const title = (record?.title || detection.title || kindLabel).trim() || kindLabel
const open = () => {
if (streaming || !sessionId || !trimmed) {
return
}
// Ensure the registry row exists even if the completion effect hasn't
// fired yet (e.g. clicked in the same frame the stream sealed).
const result = upsertArtifact(sessionId, detection, trimmed)
if (!result) {
return
}
openArtifactTab(result.artifactId)
// An older card opens at ITS version, not silently the newest — the user
// clicked this specific iteration. openArtifactTab resets to newest, so
// re-pin when this card's content is a historical version.
const versionIndex = result.record.versions.findIndex(version => version.content === trimmed)
if (versionIndex !== -1 && versionIndex < result.record.versions.length - 1) {
selectArtifactVersion(result.artifactId, versionIndex)
}
}
return (
<button
className={cn(
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors',
streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40'
)}
data-slot="aui_artifact-card"
disabled={streaming}
onClick={open}
type="button"
>
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground">
<CodeCardIcon className="text-[1rem]" name={detectionIcon(detection)} />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
'block truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground',
streaming && 'shimmer text-foreground/55'
)}
>
{title}
</span>
<span className="block truncate text-[length:var(--conversation-tool-font-size)] text-muted-foreground">
{streaming
? copy.generating(lineCount)
: versionCount > 1
? `${kindLabel} · ${copy.versionBadge(versionCount)}`
: kindLabel}
</span>
</span>
{!streaming && (
<span className="shrink-0 text-[length:var(--conversation-tool-font-size)] font-medium text-muted-foreground opacity-0 transition-opacity group-hover/artifact:opacity-100">
{copy.open}
</span>
)}
</button>
)
}

View file

@ -0,0 +1,74 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $artifactTabs, artifactsForSession, clearArtifactRegistry } from '@/store/artifacts'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { MarkdownTextContent } from './markdown-text'
const HTML_DOC = `<!doctype html>
<html>
<head><title>Pomodoro Timer</title></head>
<body>
<h1>Pomodoro</h1>
<p>A tiny focus timer that counts down twenty-five minutes.</p>
<script>let seconds = 25 * 60; setInterval(() => { seconds -= 1 }, 1000)</script>
</body>
</html>`
const SMALL_SNIPPET = 'const x = 1'
function fenced(language: string, body: string): string {
return `Here you go:\n\n\`\`\`${language}\n${body}\n\`\`\`\n`
}
// End-to-end for the artifact path: a substantial ```html fence in assistant
// markdown must come out of preprocessMarkdown -> Streamdown -> SyntaxHighlighter
// as an artifact card (registered in the store), while small fences keep the
// plain code-card path.
describe('MarkdownTextContent artifacts', () => {
beforeEach(() => {
$activeSessionId.set('session-artifacts')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearArtifactRegistry()
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearArtifactRegistry()
window.localStorage.clear()
})
it('renders a substantial html fence as an artifact card and registers it', async () => {
render(<MarkdownTextContent isRunning={false} text={fenced('html', HTML_DOC)} />)
const card = await screen.findByText('Pomodoro Timer')
expect(card.closest('button')?.dataset.slot).toBe('aui_artifact-card')
expect(artifactsForSession('session-artifacts')).toHaveLength(1)
expect(artifactsForSession('session-artifacts')[0]?.kind).toBe('html')
// Registration alone must not open the rail (offer, don't hijack).
expect($artifactTabs.get()).toHaveLength(0)
})
it('keeps a small fence as a plain code block', async () => {
const { container } = render(<MarkdownTextContent isRunning={false} text={fenced('js', SMALL_SNIPPET)} />)
// The code card mounts synchronously; Shiki may split tokens into spans,
// so assert on the card slots rather than text content.
expect(container.querySelector('[data-slot="code-card"]')).not.toBeNull()
expect(container.querySelector('[data-slot="aui_artifact-card"]')).toBeNull()
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
})
it('does not register while the message is still streaming', async () => {
render(<MarkdownTextContent isRunning text={fenced('html', HTML_DOC)} />)
await screen.findByText('Pomodoro Timer')
expect(artifactsForSession('session-artifacts')).toHaveLength(0)
})
})

View file

@ -14,6 +14,7 @@ import { ExpandableBlock } from '@/components/chat/expandable-block'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { detectArtifact } from '@/lib/artifact-detect'
import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link'
import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { parseMarkdownIntoBlocksCached } from '@/lib/markdown-blocks'
@ -33,6 +34,7 @@ import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { sessionRefFromMarkdownHref } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
@ -355,6 +357,9 @@ interface MarkdownTextSurfaceProps {
containerClassName?: string
containerProps?: ComponentProps<'div'>
defer?: boolean
/** Disable artifact-card promotion for fenced blocks (reasoning text a
* model's scratchpad draft must not register artifact versions). */
disableArtifacts?: boolean
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
@ -403,7 +408,12 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
)
}
function MarkdownTextSurface({ containerClassName, containerProps, defer }: MarkdownTextSurfaceProps) {
function MarkdownTextSurface({
containerClassName,
containerProps,
defer,
disableArtifacts
}: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
@ -508,18 +518,28 @@ function MarkdownTextSurface({ containerClassName, containerProps, defer }: Mark
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
// ```mermaid / ```svg fences route to their lazy renderers; every other
// language falls back to the Shiki-highlighted code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
language={props.language}
streaming={isStreaming}
/>
)
// ```mermaid / ```svg fences route to their lazy renderers; substantial
// html/svg/code fences promote to an artifact card that opens in the
// right rail; every other language falls back to the Shiki-highlighted
// code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code)
if (artifact) {
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
}
return (
<RichCodeBlock
code={props.code}
fallback={<SyntaxHighlighter {...props} defer={isStreaming} />}
language={props.language}
streaming={isStreaming}
/>
)
}
}) as StreamdownTextComponents,
[isStreaming]
[disableArtifacts, isStreaming]
)
if (text.length > MAX_MARKDOWN_CHARS) {

View file

@ -13,8 +13,13 @@ const isTransientLookupError = (error: unknown): boolean =>
error instanceof Error && /(useClientLookup|tapClient(Lookup|Resource)).*out of bounds/.test(error.message)
interface Props {
// Changes whenever the message list mutates; remounting clears the caught
// error so the next consistent render recovers silently.
// Changes whenever the message list mutates STRUCTURALLY (ids/roles/count);
// remounting clears the caught error so the next consistent render recovers
// silently. Deliberately NOT the per-token signature: this prop reaches
// every turn's boundary, so a value that ticks with content length would
// re-render every boundary — and reconcile every turn's subtree — on every
// streamed token (measured: 540 wasted Block renders per explain() sample
// with two threads streaming).
resetKey: string
children: ReactNode
}

View file

@ -8,7 +8,8 @@ import { MarkdownTextContent } from './markdown-text'
const openSessionTile = vi.fn()
vi.mock('@/store/session-states', () => ({
vi.mock('@/store/session-states', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
openSessionTile: (...args: unknown[]) => openSessionTile(...args)
}))

View file

@ -0,0 +1,92 @@
// Bug #2: the Branch-in-new-chat button used to render unconditionally even
// when its handler was a no-op (session-tile.tsx passed `() => undefined`
// for branched/tiled chats, where nested branching isn't supported). That
// left a visibly clickable button that silently did nothing. The fix makes
// AssistantMessage's action bar hide the button entirely when no handler is
// supplied, matching how onDismissError/onRestoreToMessage already behave.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Thread } from '.'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
vi.stubGlobal('CSS', { escape: (str: string) => str })
Element.prototype.scrollTo = function scrollTo() {}
afterEach(() => {
cleanup()
})
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'question one' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
function Harness({ onBranchInNewChat }: { onBranchInNewChat?: (messageId: string) => void }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} />
</AssistantRuntimeProvider>
)
}
describe('AssistantMessage branch button visibility (bug #2 fix)', () => {
it('shows the Branch in new chat button when a handler is provided (open chat)', async () => {
render(<Harness onBranchInNewChat={() => undefined} />)
expect(await screen.findByRole('button', { name: 'Branch in new chat' })).toBeTruthy()
})
it('hides the Branch in new chat button when no handler is provided (session-tile / branched chat)', async () => {
render(<Harness />)
// Wait for the assistant message to actually mount before asserting
// absence, so a missing button isn't just a false negative from an
// unrendered message.
await screen.findByText('done')
expect(screen.queryByRole('button', { name: 'Branch in new chat' })).toBeNull()
})
})

View file

@ -151,15 +151,17 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
data-slot="aui_msg-actions"
>
<MessageAge />
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat?.(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
{onBranchInNewChat && (
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
)}
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>

View file

@ -1,4 +1,4 @@
import { type FC, useCallback, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
@ -16,7 +16,7 @@ import { notifyError } from '@/store/notifications'
type ThreadLoadingState = 'response' | 'session'
export const Thread: FC<{
interface ThreadProps {
clampToComposer?: boolean
cwd?: string | null
gateway?: HermesGateway | null
@ -28,7 +28,16 @@ export const Thread: FC<{
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
}> = ({
}
// memo'd on purpose, and load-bearing for session-switch cost. ChatView
// re-renders on every route change (it reads `location`), and this subtree is
// the entire transcript — without a bail-out here the router's context update
// rebuilds every message of the OUTGOING thread before it is replaced. The
// props above are all stable across a plain re-render (see the component-map
// and loadingIndicator memos below), so the only thing that gets through is a
// genuine change.
export const Thread = memo(function Thread({
clampToComposer = false,
cwd = null,
gateway = null,
@ -40,7 +49,7 @@ export const Thread: FC<{
onRestoreToMessage,
sessionId = null,
sessionKey
}) => {
}: ThreadProps) {
const { t } = useI18n()
const copy = t.assistant.thread
@ -77,9 +86,21 @@ export const Thread: FC<{
// deps. Only their definedness stays a dep — it gates UI (the user
// Stop button, the restore-confirm affordance). Assigned during render
// (the useStoreSelector pattern) so the ref never lags a render.
//
// cwd / gateway / sessionId ride the same ref for the same reason, and it
// is load-bearing on the hot path: all three change on EVERY session
// switch, so listing them as deps re-minted these types mid-switch and
// remounted the entire OUTGOING transcript — thousands of renders of a
// thread that was about to be replaced, all of it before the resume RPC
// had even been sent. They are read inside the edit composer (which only
// exists while a message is being edited), never during a plain render,
// so a ref read is always current by the time it matters.
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
const editContextRef = useRef({ cwd, gateway, sessionId })
editContextRef.current = { cwd, gateway, sessionId }
const hasBranchInNewChat = Boolean(onBranchInNewChat)
const hasCancel = Boolean(onCancel)
const hasDismissError = Boolean(onDismissError)
@ -96,7 +117,11 @@ export const Thread: FC<{
/>
),
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserEditComposer: () => {
const { cwd: editCwd, gateway: editGateway, sessionId: editSessionId } = editContextRef.current
return <UserEditComposer cwd={editCwd} gateway={editGateway} sessionId={editSessionId} />
},
UserMessage: () => (
<UserMessage
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
@ -104,16 +129,7 @@ export const Thread: FC<{
/>
)
}),
[
cwd,
gateway,
hasBranchInNewChat,
hasCancel,
hasDismissError,
hasRestoreToMessage,
requestRestoreConfirm,
sessionId
]
[hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm]
)
const emptyPlaceholder = intro ? (
@ -122,13 +138,20 @@ export const Thread: FC<{
</div>
) : undefined
// Stable element identity, for the same reason the component map above is
// memoized: this is a prop of the memo'd ThreadMessageList, so a fresh
// element every render defeats the bail-out and drags the whole transcript
// into the switch's render pass. It takes no props, so one element is
// always correct.
const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, [])
return (
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={<BackgroundResumeNotice />}
loadingIndicator={loadingIndicator}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
@ -144,4 +167,4 @@ export const Thread: FC<{
/>
</div>
)
}
})

View file

@ -9,6 +9,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react'
@ -46,7 +47,14 @@ const RENDER_BUDGET = 300
// the user actually sees after scroll-to-bottom), then bump to the full budget
// in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render
// past the initial commit, so the switch feels instant.
const FIRST_PAINT_BUDGET = 60
//
// 20, down from 60: the first-paint commit is synchronous and uninterruptible,
// and at 60 parts it measured 627ms on a real session (LoAF: block=575ms, no
// attributed script — pure commit). A viewport after scroll-to-bottom shows
// 1-2 turns ≈ 10-20 parts; the transition backfill below fills the rest
// interruptibly, so the only thing a smaller budget changes is how much work
// blocks the click-to-paint path.
const FIRST_PAINT_BUDGET = 20
interface ThreadMessageListProps {
clampToComposer: boolean
@ -141,14 +149,25 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
loadingIndicator,
sessionKey
}) => {
const messageSignature = useAuiState(s =>
s.thread.messages
.map((message, index) => `${index}:${message.id}:${message.role}:${message.content?.length ?? 1}`)
.join('\n')
// TWO signatures, deliberately split. The STRUCTURAL one (ids/roles/count)
// changes only when messages are added/removed/swapped — it keys the error
// boundaries and the row identity. The WEIGHT one (per-message part counts)
// ticks while a streaming turn appends parts — it feeds only the render
// budget. Folding weights into the structural key handed every boundary a
// new resetKey per appended part, which reconciled every turn's subtree on
// every tick (measured: 540 wasted Block renders per explain() sample with
// two threads streaming).
const structuralSignature = useAuiState(s =>
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
)
const weightSignature = useAuiState(s => s.thread.messages.map(message => message.content?.length ?? 1).join(','))
const { t } = useI18n()
const groups = buildGroups(messageSignature)
// Row structure is memoized on the STRUCTURAL signature only, so streaming
// part-appends can't churn group identity (that would defeat the rows memo
// below on every tick). Weights are folded in separately for the budget.
const groups = useMemo(() => buildGroups(structuralSignature), [structuralSignature])
const renderEmpty = groups.length === 0 && Boolean(emptyPlaceholder)
// use-stick-to-bottom owns scrollTop (single writer): follow while locked,
@ -213,7 +232,22 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
return () => cancelAnimationFrame(rafId)
}, [renderBudget])
const hiddenCount = firstVisibleGroupIndex(groups, renderBudget)
// Weights (per-message part counts) fold into the BUDGET only. Group
// identity stays structural, so a streaming append re-runs this cheap sum —
// not the row JSX. Weighted the same way the old combined signature was.
const weightedGroups = useMemo(() => {
const weights = weightSignature.split(',').map(w => Number(w) || 1)
return groups.map(group => ({
...group,
weight:
group.kind === 'turn'
? group.indices.reduce((sum, index) => sum + (weights[index] ?? 1), 0)
: (weights[group.index] ?? 1)
}))
}, [groups, weightSignature])
const hiddenCount = firstVisibleGroupIndex(weightedGroups, renderBudget)
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
const restoreFromBottomRef = useRef<number | null>(null)
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
@ -331,6 +365,59 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
}
}, [scrollRef, renderBudget])
// The row array is memoized on the inputs the rows actually read. This
// component re-renders on every isAtBottom flip — and use-stick-to-bottom
// flips it from a ResizeObserver, so a sidebar DRAG re-renders this list per
// frame. Without the memo, the inline .map() rebuilt every row's JSX each
// time, and rebuilt children re-render their whole subtree even when nothing
// changed (measured live: 865 wasted Block renders in one drag, walked to
// "MessageRenderBoundary (children only)" by explain()). With it, React
// bails out on element identity and a scroll flip re-renders nothing below.
const rows = useMemo(
() =>
visibleGroups.map((group, indexInVisible) => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
// reads getComputedStyle) forces a whole-document style recalc,
// measured ~650-730ms per open on a 1300-message session and
// ~100-200ms with this on. contain-intrinsic-size keeps a
// placeholder height for never-rendered turns (auto: remembered
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
<MessageRenderBoundary resetKey={structuralSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
)),
[visibleGroups, components, structuralSignature]
)
return (
<div
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
@ -380,46 +467,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
{t.assistant.thread.showEarlier}
</button>
)}
{visibleGroups.map((group, indexInVisible) => (
// content-visibility:auto — off-screen turns skip style recalc,
// layout, and paint. On a long transcript this is what keeps
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
// reads getComputedStyle) forces a whole-document style recalc,
// measured ~650-730ms per open on a 1300-message session and
// ~100-200ms with this on. contain-intrinsic-size keeps a
// placeholder height for never-rendered turns (auto: remembered
// real size once rendered), so scrollbar/anchoring stay stable.
// Sticky human bubbles are unaffected — their turn is rendered
// whenever any part of it intersects the viewport.
//
// The live tail (newest turns) is exempt: virtualizing a turn
// whose final size hasn't been remembered yet snaps it to a stale
// height when it scrolls off, drifting stick-to-bottom up over old
// turns. See isVirtualizedGroup.
<div
className={cn(
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
)}
key={group.id}
>
<MessageRenderBoundary resetKey={messageSignature}>
{group.kind === 'turn' ? (
<div
className="composer-human-ai-pair-container relative flex min-w-0 flex-col gap-(--conversation-turn-gap)"
data-slot="aui_turn-pair"
>
{group.indices.map(index => (
<ThreadPrimitive.MessageByIndex components={components} index={index} key={index} />
))}
</div>
) : (
<ThreadPrimitive.MessageByIndex components={components} index={group.index} />
)}
</MessageRenderBoundary>
</div>
))}
{rows}
{loadingIndicator}
{clampToComposer && (
<div

View file

@ -87,8 +87,20 @@ const ThinkingDisclosure: FC<{
return
}
const pin = () => {
el.scrollTop = el.scrollHeight
// Height-gated: the observer also fires when the container's WIDTH changes
// (sidebar sash drag resizes every message), and pinning there forces a
// scrollHeight read+write per preview per frame. Only actual content
// growth needs the pin; the height rides the RO entry, reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (grew) {
el.scrollTop = el.scrollHeight
}
}
// No sync pin(): the observer's guaranteed initial delivery runs it with
@ -198,6 +210,7 @@ const ReasoningTextPart: ReasoningMessagePartComponent = () => {
<MarkdownTextContent
containerClassName="text-xs leading-snug text-muted-foreground/85"
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
disableArtifacts
isRunning={status.type === 'running' || messageRunning}
text={text.trimStart()}
/>

View file

@ -755,7 +755,25 @@ function useToolWindow(enabled: boolean) {
return
}
const pin = () => {
// Track the content's HEIGHT and only pin when it grows. The observer also
// fires for width changes — a sidebar sash drag resizes every tool window
// once per frame — and pinning there is (a) pointless, the list didn't
// grow, and (b) expensive: `pin` writes scrollTop then `syncFade` reads it
// back, a write->read forced reflow per tool group per frame. Measured on
// a real session while dragging the sash: 927ms of `pin` script plus
// 2.7s of style recalc across one 60-frame drag. Reading the height off
// the RO entry keeps the check reflow-free.
let lastHeight = -1
const pin = (entries: readonly ResizeObserverEntry[]) => {
const height = entries[entries.length - 1]?.borderBoxSize?.[0]?.blockSize ?? -1
const grew = height < 0 || height > lastHeight
lastHeight = height
if (!grew) {
return
}
if (stickRef.current) {
el.scrollTop = el.scrollHeight
}

View file

@ -3,6 +3,7 @@ import { useState } from 'react'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { modelSearchText } from '@/lib/model-search-text'
import { currentPickerSelection } from '@/lib/model-status-label'
import { normalize } from '@/lib/text'
import type { ModelOptionProvider, ModelPricing } from '@/types/hermes'
@ -172,7 +173,7 @@ function ModelResults({
const matches = (provider: ModelOptionProvider, model: string) =>
!q ||
model.toLowerCase().includes(q) ||
modelSearchText(model).toLowerCase().includes(q) ||
provider.name.toLowerCase().includes(q) ||
provider.slug.toLowerCase().includes(q)

View file

@ -158,6 +158,31 @@ function sameRect(a: Rect | null, b: Rect | null) {
// Workspace-edge CSS vars
// ---------------------------------------------------------------------------
// --- Sash-drag deferral ------------------------------------------------------
// The tree sash sets this for the duration of a resize gesture (pointerdown to
// pointerup). While set, `publishWorkspaceGeometry` skips its :root custom
// property writes: each one invalidates computed style for the whole document,
// and the sash's ResizeObserver fires per frame. Measured live (LoAF, real
// session): drag frames of ~68ms with style+layout=67ms and no script ≥5ms;
// suppressing the writes recovered 14fps → 51fps. The vars only align titlebar
// chrome — republishing once on release is visually identical.
let sashDragDepth = 0
let onSashDragEnd: null | (() => void) = null
export function beginSashDrag() {
sashDragDepth += 1
}
export function endSashDrag() {
sashDragDepth = Math.max(0, sashDragDepth - 1)
if (sashDragDepth === 0) {
onSashDragEnd?.()
}
}
const sashDragging = () => sashDragDepth > 0
/**
* Publish the workspace zone's viewport edges as root CSS vars:
* --workspace-left : px from the viewport's left to the main zone
@ -177,6 +202,12 @@ export function publishWorkspaceGeometry(): () => void {
const ro = new ResizeObserver(() => measure())
const measure = () => {
// DEFER during a sash drag (see beginSashDrag above) — republished once on
// release via the onSashDragEnd hook registered below.
if (sashDragging()) {
return
}
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
if (next !== el) {
@ -216,11 +247,14 @@ export function publishWorkspaceGeometry(): () => void {
// frame later, after the DOM committed. RO covers width changes (sash drags,
// side collapses); window resize covers the rest.
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
// Drag released → publish the final geometry the deferral above skipped.
onSashDragEnd = () => requestAnimationFrame(measure)
window.addEventListener('resize', measure)
measure()
return () => {
unsubTree()
onSashDragEnd = null
window.removeEventListener('resize', measure)
ro.disconnect()
root.style.removeProperty('--workspace-left')

View file

@ -9,6 +9,7 @@
import { useStore } from '@nanostores/react'
import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import { beginSashDrag, endSashDrag } from '@/components/pane-shell/geometry'
import { useContributions } from '@/contrib/react/use-contributions'
import { rafCoalesce } from '@/lib/raf-coalesce'
import { cn } from '@/lib/utils'
@ -234,9 +235,33 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
document.body.style.userSelect = 'none'
// Suppress :root geometry-var writes for the gesture (see geometry.ts —
// each one restyles the whole document; they republish on release).
beginSashDrag()
// pointermove outpaces 60fps and each write relayouts the whole pane tree,
// so coalesce to one apply per frame (rafCoalesce commits on cleanup).
//
// During the gesture the store is NOT written. setTreeSplitWeights /
// setPaneWidthOverride each mint a new tree/pane-state object, and the
// resulting commit walks every mounted pane — measured live on a real
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
// store is written ONCE on release; during the drag the seam is
// previewed with inline styles on the same wrappers React sizes.
//
// Preview rules (learned the hard way — a wrong shape here left a
// phantom gap where a hidden sidebar lived):
// - a FIXED side gets ONLY a flex-basis override. Its wrapper renders
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
// shrink stay React's. Crucially the flex partner is left untouched,
// so it keeps absorbing the remainder and no leftover gap can open.
// - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined
// px is constant, so sibling flex tracks see the same leftover.
// - cleanup: a real drag commits the store once, and React's re-render
// rewrites the `flex` shorthand, which clears the overrides (writing
// the shorthand resets the longhands). A no-movement click restores
// the captured style attribute instead, since nothing re-renders.
const applyShift = (shiftPx: number) => {
if (a.fixed) {
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
@ -255,14 +280,58 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
}
}
const resize = rafCoalesce(applyShift)
const styleA = kidA.getAttribute('style')
const styleB = kidB.getAttribute('style')
const previewSide = (el: HTMLElement, fixed: boolean, px: number) => {
if (fixed) {
el.style.flexBasis = `${px}px`
} else if (!a.fixed && !b.fixed) {
el.style.flex = `0 1 ${px}px`
}
// Mixed seam, flex side: untouched — it absorbs what the fixed side
// gives up, exactly as the track model would render it.
}
const previewShift = (shiftPx: number) => {
previewSide(kidA, a.fixed, a0px + shiftPx)
previewSide(kidB, b.fixed, b0px - shiftPx)
}
const resize = rafCoalesce(previewShift)
let lastShift: null | number = null
const onMove = (ev: PointerEvent) => {
resize.push(Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)))
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
resize.push(lastShift)
}
const cleanup = () => {
resize.finish()
if (lastShift !== null) {
// One store commit; the re-render rewrites `flex` and clears the
// preview overrides.
applyShift(lastShift)
} else {
// Click without movement: nothing will re-render, so put the
// wrappers' inline styles back exactly as React last wrote them.
if (styleA === null) {
kidA.removeAttribute('style')
} else {
kidA.setAttribute('style', styleA)
}
if (styleB === null) {
kidB.removeAttribute('style')
} else {
kidB.setAttribute('style', styleB)
}
}
// Geometry vars re-enable AFTER the final store commit above, so the
// release publishes exactly one fresh measurement.
endSashDrag()
document.body.style.cursor = restoreCursor
document.body.style.userSelect = restoreSelect

View file

@ -25,6 +25,10 @@
// with no changed input, and stores that published a value equal to the last.
import './render-counter'
// Live interaction profiler — arms on real resize/typing so we can measure the
// app under REAL sessions instead of a synthetic scenario's toy transcripts.
// window.__PERF_LIVE__.on() in the console, then just use the app.
import './perf-live'
import { watchSessionAtoms } from './watched-atoms'

View file

@ -0,0 +1,306 @@
// Live interaction profiler — for driving the app by hand and seeing what the
// app actually does under YOUR sessions, not a synthetic scenario's.
//
// The synthetic scenarios seed toy transcripts (short prose, no tool calls, no
// code blocks). A real session is heavier in ways that matter, so a scenario
// can report 57fps on a gesture that visibly lags in the real app. This closes
// that gap by measuring the real thing.
//
// It arms itself on any pointerdown on a resize handle and on composer typing,
// records frames + render attribution for the duration of the interaction, and
// prints a table when the interaction ends. Idle cost is zero: nothing is
// observed until an interaction starts.
//
// window.__PERF_LIVE__.on() start watching (also: ?perflive=1)
// window.__PERF_LIVE__.off()
// window.__PERF_LIVE__.last() the most recent report as an object
//
// Dev-only; the whole debug/ graph is aliased out of production builds.
interface Sample {
kind: string
ms: number
frames: number
fps: number
p95: number
worst: number
slow33: number
commits: number
top: Array<{ name: string; renders: number; wasted: number; totalMs: number }>
longFrames: LongFrame[]
}
/** One Long Animation Frame, attributed. `styleMs` is the engine's style+layout
* time inside the frame; `scripts` names who ran JS and for how long. This is
* the half the render counter cannot see a frame can cost 900ms with almost
* no React in it, and only LoAF says whether that was layout, a ResizeObserver
* callback loop, or some timer. */
interface LongFrame {
ms: number
styleMs: number
blockingMs: number
scripts: Array<{ invoker: string; ms: number; src: string }>
}
const RESIZE_SELECTOR =
'[role="separator"], [data-slot="pane-resize-handle"], [class*="cursor-col-resize"], [class*="cursor-row-resize"]'
const TYPING_SELECTOR = '[contenteditable="true"], textarea, input[type="text"]'
// A gesture is "over" once this long passes with no further input events.
const IDLE_END_MS = 350
// Don't report trivial blips (a click, a single keypress).
const MIN_FRAMES = 6
let watching = false
let active: null | {
kind: string
startedAt: number
frames: number[]
last: number
raf: number
endTimer: ReturnType<typeof setTimeout> | null
} = null
let lastReport: null | Sample = null
// Long Animation Frames observed while a gesture is active. LoAF entries name
// the scripts inside each long frame and split out style/layout time — the
// half of a frame the render counter cannot see.
let longFrames: LongFrame[] = []
const loafObserver =
typeof PerformanceObserver !== 'undefined' &&
PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame')
? new PerformanceObserver(list => {
if (!active) {
return
}
for (const entry of list.getEntries()) {
const e = entry as PerformanceEntry & {
blockingDuration?: number
styleAndLayoutStart?: number
renderStart?: number
scripts?: Array<{
duration: number
invoker?: string
invokerType?: string
sourceURL?: string
sourceFunctionName?: string
}>
}
longFrames.push({
blockingMs: Math.round(e.blockingDuration ?? 0),
ms: Math.round(e.duration),
scripts: (e.scripts ?? [])
.filter(s => s.duration >= 5)
.map(s => ({
invoker: `${s.invokerType ?? ''}:${s.invoker ?? s.sourceFunctionName ?? '?'}`,
ms: Math.round(s.duration),
src: (s.sourceURL ?? '').split('/').pop() ?? ''
})),
// styleAndLayoutStart -> frame end is the engine's style+layout tail.
styleMs: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0
})
}
})
: null
const pct = (sorted: number[], p: number) =>
sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
function finish() {
if (!active) {
return
}
const { kind, startedAt, frames, raf } = active
active = null
cancelAnimationFrame(raf)
loafObserver?.disconnect()
const capturedLongFrames = longFrames
longFrames = []
const counter = window.__RENDER_COUNTS__
const commits = counter?.commits() ?? 0
const top = (counter?.report(12) ?? []).map(r => ({
name: r.name,
renders: r.renders,
wasted: r.wasted,
totalMs: r.totalMs
}))
counter?.stop()
if (frames.length < MIN_FRAMES) {
return
}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const report: Sample = {
commits,
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
frames: frames.length,
kind,
longFrames: capturedLongFrames,
ms: Math.round(performance.now() - startedAt),
p95: Math.round(pct(sorted, 0.95) * 10) / 10,
slow33: frames.filter(f => f > 33).length,
top,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10
}
lastReport = report
const headline =
`%c${kind}%c ${report.fps}fps · ${report.frames} frames in ${report.ms}ms · ` +
`p95 ${report.p95}ms worst ${report.worst}ms · ${report.slow33} slow · ${commits} commits`
console.log(
headline,
`background:${report.fps < 45 ? '#c0392b' : '#27ae60'};color:#fff;padding:1px 6px;border-radius:3px`,
'color:inherit'
)
if (top.length) {
console.table(top)
}
// The frame-engine side: what each long frame actually spent its time on.
for (const lf of capturedLongFrames.slice(0, 8)) {
const scripts = lf.scripts.map(s => `${s.invoker}@${s.src} ${s.ms}ms`).join(' | ') || '(no script ≥5ms)'
console.log(` ⏱ longframe ${lf.ms}ms style+layout ${lf.styleMs}ms block ${lf.blockingMs}ms → ${scripts}`)
}
}
function begin(kind: string) {
if (active) {
// Same gesture continuing — just push the end deadline out.
if (active.endTimer) {
clearTimeout(active.endTimer)
}
active.endTimer = setTimeout(finish, IDLE_END_MS)
return
}
window.__RENDER_COUNTS__?.start()
try {
// Buffered so a long frame already in flight when the gesture starts is
// still attributed to it.
loafObserver?.observe({ buffered: true, type: 'long-animation-frame' })
} catch {
// Older runtime without LoAF — headline still works, attribution is empty.
}
const now = performance.now()
const state = {
endTimer: setTimeout(finish, IDLE_END_MS) as ReturnType<typeof setTimeout> | null,
frames: [] as number[],
kind,
last: now,
raf: 0,
startedAt: now
}
const tick = () => {
if (active !== state) {
return
}
const t = performance.now()
state.frames.push(t - state.last)
state.last = t
state.raf = requestAnimationFrame(tick)
}
active = state
state.raf = requestAnimationFrame(tick)
}
function onPointerDown(event: PointerEvent) {
const target = event.target as Element | null
if (target?.closest?.(RESIZE_SELECTOR)) {
begin('resize')
}
}
function onPointerMove() {
if (active?.kind === 'resize') {
begin('resize')
}
}
function onKeyDown(event: KeyboardEvent) {
const target = event.target as Element | null
// Ignore pure modifiers and navigation — we want real text entry.
if (event.key.length !== 1 && event.key !== 'Backspace') {
return
}
if (target?.closest?.(TYPING_SELECTOR)) {
begin('typing')
}
}
function on() {
if (watching) {
return 'already watching'
}
watching = true
window.addEventListener('pointerdown', onPointerDown, true)
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('keydown', onKeyDown, true)
console.log(
'%cperf-live%c armed — resize a pane or type in the composer; a report prints when you stop.',
'background:#5a7db0;color:#fff;padding:1px 6px;border-radius:3px',
'color:inherit'
)
return 'watching'
}
function off() {
watching = false
window.removeEventListener('pointerdown', onPointerDown, true)
window.removeEventListener('pointermove', onPointerMove, true)
window.removeEventListener('keydown', onKeyDown, true)
finish()
return 'stopped'
}
declare global {
interface Window {
__PERF_LIVE__?: {
on: () => string
off: () => string
last: () => null | Sample
watching: () => boolean
}
}
}
if (typeof window !== 'undefined' && !window.__PERF_LIVE__) {
window.__PERF_LIVE__ = { last: () => lastReport, off, on, watching: () => watching }
// Opt in for a whole session with ?perflive=1 so a reload keeps measuring.
if (new URLSearchParams(window.location.search).get('perflive') === '1') {
on()
}
}
export {}

View file

@ -46,6 +46,12 @@ const counts = new Map<string, RenderRecord>()
let commits = 0
let recording = false
// explain() state: while set, every wasted render of this component walks up
// the fiber tree to find the ancestor whose props/state/context actually
// changed — the origin of the cascade.
let explainTarget: null | string = null
const explainCauses = new Map<string, number>()
const blank = (): RenderRecord => ({
contextChanged: 0,
propsChanged: 0,
@ -76,19 +82,51 @@ function propsChanged(fiber: Fiber): boolean {
/** Did any hook's memoizedState change? Covers useState, useSyncExternalStore
* (so nanostores `useStore`), useMemo, and useReducer alike. */
function stateChanged(fiber: Fiber): boolean {
return changedHookIndices(fiber).length > 0
}
/** Indices (source order) of the hooks whose memoizedState changed. The index
* maps straight onto the component's hook call order, so "hook #3 changed"
* identifies the exact useStore/useState line without guessing. */
function changedHookIndices(fiber: Fiber): number[] {
let next: Fiber['memoizedState'] | null | undefined = fiber.memoizedState
let prev: Fiber['memoizedState'] | null | undefined = fiber.alternate?.memoizedState
const changed: number[] = []
let index = 0
while (next && prev) {
if (!Object.is(next.memoizedState, prev.memoizedState)) {
return true
changed.push(index)
}
next = next.next
prev = prev.next
index += 1
}
return false
return changed
}
/** Names of the props whose identity changed the cascade origin's smoking
* gun. Used by explain() so the answer is "Streamdown (props: children)" and
* not just "Streamdown (props)". */
function changedPropKeys(fiber: Fiber): string[] {
const prev = fiber.alternate?.memoizedProps as Record<string, unknown> | null | undefined
const next = fiber.memoizedProps as Record<string, unknown> | null | undefined
if (!prev || !next) {
return []
}
const keys: string[] = []
for (const key of Object.keys(next)) {
if (!Object.is(prev[key], next[key])) {
keys.push(key)
}
}
return keys
}
/** Did any consumed context value change? A `memo()` cannot block a re-render
@ -139,6 +177,46 @@ function record(fiber: Fiber) {
if (!props && !state && !context) {
entry.wasted += 1
// explain() support: walk UP from a wasted render to the TOP of the
// cascade — the highest ancestor that also rendered this commit. That
// fiber is the origin; its own changed props/state is the reason.
// Stopping at the first ancestor with changed props is wrong: JSX rebuilt
// by a parent makes every intermediate node report "children changed",
// which is the symptom cascading down, not the cause.
if (explainTarget && name === explainTarget) {
let origin: Fiber = fiber
let cursor = fiber.return
let hops = 0
while (cursor && hops < 80) {
if (isCompositeFiber(cursor) && didFiberRender(cursor)) {
origin = cursor
}
cursor = cursor.return
hops += 1
}
const originName = getDisplayName(origin) ?? '?'
const changed = changedPropKeys(origin).filter(k => k !== 'children')
const why =
origin === fiber
? 'self'
: stateChanged(origin)
? `state (hooks #${changedHookIndices(origin).slice(0, 5).join(',#')})`
: changed.length
? `props: ${changed.slice(0, 4).join(',')}`
: contextChanged(origin)
? 'context'
: changedPropKeys(origin).length
? 'children only'
: 'no visible change (external store?)'
const key = `${originName} (${why})`
explainCauses.set(key, (explainCauses.get(key) ?? 0) + 1)
}
}
counts.set(name, entry)
@ -168,6 +246,12 @@ declare global {
report: (limit?: number) => Array<RenderRecord & { name: string }>
/** Attribution for one component by display name. */
get: (name: string) => RenderRecord | undefined
/**
* Name a component (its displayName, e.g. 'Block'), interact, then call
* with no argument to get the tally of which CHANGED ancestor each of
* its wasted renders cascaded from. The origin, walked not guessed.
*/
explain: (name?: null | string) => Record<string, number> | string
}
}
}
@ -195,6 +279,20 @@ if (typeof window !== 'undefined' && !window.__RENDER_COUNTS__) {
},
commits: () => commits,
counts,
explain: name => {
if (name !== undefined) {
explainTarget = name
explainCauses.clear()
if (name && !recording) {
recording = true
}
return name ? `explaining ${name} — interact, then call explain() to read` : 'explain off'
}
return Object.fromEntries([...explainCauses.entries()].sort((x, y) => y[1] - x[1]))
},
get: name => counts.get(name),
recording: () => recording,
report,

View file

@ -1495,6 +1495,29 @@ export const ar = defineLocale({
copyUrl: 'نسخ الرابط',
copyPath: 'نسخ المسار'
},
artifactCard: {
kind: { code: 'كود', html: 'صفحة تفاعلية', svg: 'رسم' },
generating: lines => `جارٍ الإنشاء… ${lines} سطرًا`,
versionBadge: count => `${count} إصدارات`,
open: 'فتح'
},
artifactPane: {
tabFallback: 'ناتج',
modePreview: 'معاينة',
modeSource: 'المصدر',
versionOf: (current, total) => `الإصدار ${current} من ${total}`,
olderVersion: 'إصدار أقدم',
newerVersion: 'إصدار أحدث',
latest: 'الأحدث',
copyContent: 'نسخ المحتوى',
download: 'تنزيل',
openInBrowser: 'فتح في المتصفح',
openInBrowserFailed: 'تعذّر الفتح في المتصفح',
missingTitle: 'الناتج غير متاح',
missingBody: 'لم يعد هذا الناتج موجودًا في السجل المحلي.'
},
sidebar: {
nav: {
'new-session': 'جلسة جديدة',

View file

@ -1757,6 +1757,29 @@ export const en: Translations = {
copyPath: 'Copy path'
},
artifactCard: {
kind: { code: 'Code', html: 'Interactive page', svg: 'Graphic' },
generating: lines => `Generating… ${lines} lines`,
versionBadge: count => `${count} versions`,
open: 'Open'
},
artifactPane: {
tabFallback: 'Artifact',
modePreview: 'PREVIEW',
modeSource: 'SOURCE',
versionOf: (current, total) => `v${current} of ${total}`,
olderVersion: 'Older version',
newerVersion: 'Newer version',
latest: 'Latest',
copyContent: 'Copy content',
download: 'Download',
openInBrowser: 'Open in browser',
openInBrowserFailed: 'Could not open in browser',
missingTitle: 'Artifact unavailable',
missingBody: 'This artifact is no longer in the local registry.'
},
sidebar: {
nav: {
'new-session': 'New session',
@ -2407,6 +2430,9 @@ export const en: Translations = {
toggleApprovalMode: 'Approvals',
toggleBackendVersion: 'Backend version',
toggleCommandCenter: 'Command Center',
toggleContextUsage: 'Context meter',
toggleRunningTimer: 'Turn timer',
toggleSessionTimer: 'Session timer',
toggleTerminal: 'Terminal',
toggleVersion: 'Version & updates',
toggleWorkspace: 'Workspace',

View file

@ -1621,6 +1621,29 @@ export const ja = defineLocale({
copyPath: 'パスをコピー'
},
artifactCard: {
kind: { code: 'コード', html: 'インタラクティブページ', svg: 'グラフィック' },
generating: lines => `生成中… ${lines}`,
versionBadge: count => `${count} 個のバージョン`,
open: '開く'
},
artifactPane: {
tabFallback: 'アーティファクト',
modePreview: 'プレビュー',
modeSource: 'ソース',
versionOf: (current, total) => `${total} 中 v${current}`,
olderVersion: '前のバージョン',
newerVersion: '次のバージョン',
latest: '最新',
copyContent: 'コンテンツをコピー',
download: 'ダウンロード',
openInBrowser: 'ブラウザで開く',
openInBrowserFailed: 'ブラウザで開けませんでした',
missingTitle: 'アーティファクトを利用できません',
missingBody: 'このアーティファクトはローカルレジストリに存在しません。'
},
sidebar: {
nav: {
'new-session': '新しいセッション',

View file

@ -1466,6 +1466,29 @@ export interface Translations {
copyPath: string
}
artifactCard: {
kind: Record<'code' | 'html' | 'svg', string>
generating: (lines: number) => string
versionBadge: (count: number) => string
open: string
}
artifactPane: {
tabFallback: string
modePreview: string
modeSource: string
versionOf: (current: number, total: number) => string
olderVersion: string
newerVersion: string
latest: string
copyContent: string
download: string
openInBrowser: string
openInBrowserFailed: string
missingTitle: string
missingBody: string
}
sidebar: {
nav: Record<string, string>
searchAria: string
@ -2013,6 +2036,9 @@ export interface Translations {
toggleApprovalMode: string
toggleBackendVersion: string
toggleCommandCenter: string
toggleContextUsage: string
toggleRunningTimer: string
toggleSessionTimer: string
toggleTerminal: string
toggleVersion: string
toggleWorkspace: string

View file

@ -1569,6 +1569,29 @@ export const zhHant = defineLocale({
copyPath: '複製路徑'
},
artifactCard: {
kind: { code: '程式碼', html: '互動頁面', svg: '圖形' },
generating: lines => `產生中… ${lines}`,
versionBadge: count => `${count} 個版本`,
open: '開啟'
},
artifactPane: {
tabFallback: '產物',
modePreview: '預覽',
modeSource: '原始碼',
versionOf: (current, total) => `${current}/${total}`,
olderVersion: '較舊版本',
newerVersion: '較新版本',
latest: '最新',
copyContent: '複製內容',
download: '下載',
openInBrowser: '在瀏覽器中開啟',
openInBrowserFailed: '無法在瀏覽器中開啟',
missingTitle: '產物無法使用',
missingBody: '此產物已不在本機註冊表中。'
},
sidebar: {
nav: {
'new-session': '新工作階段',

View file

@ -1947,6 +1947,29 @@ export const zh: Translations = {
copyPath: '复制路径'
},
artifactCard: {
kind: { code: '代码', html: '交互页面', svg: '图形' },
generating: lines => `生成中… ${lines}`,
versionBadge: count => `${count} 个版本`,
open: '打开'
},
artifactPane: {
tabFallback: '产物',
modePreview: '预览',
modeSource: '源码',
versionOf: (current, total) => `${current}/${total}`,
olderVersion: '较旧版本',
newerVersion: '较新版本',
latest: '最新',
copyContent: '复制内容',
download: '下载',
openInBrowser: '在浏览器中打开',
openInBrowserFailed: '无法在浏览器中打开',
missingTitle: '产物不可用',
missingBody: '此产物已不在本地注册表中。'
},
sidebar: {
nav: {
'new-session': '新建会话',
@ -2583,6 +2606,9 @@ export const zh: Translations = {
toggleApprovalMode: '审批',
toggleBackendVersion: '后端版本',
toggleCommandCenter: '命令中心',
toggleContextUsage: '上下文用量',
toggleRunningTimer: '回合计时',
toggleSessionTimer: '会话计时',
toggleTerminal: '终端',
toggleVersion: '版本与更新',
toggleWorkspace: '工作区',

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { artifactContentHash, artifactDownloadName, artifactSlug, detectArtifact } from './artifact-detect'
const HTML_DOC = `<!doctype html>
<html>
<head><title>Pomodoro Timer</title></head>
<body>
<h1>Pomodoro</h1>
<script>let t = 25 * 60; setInterval(() => { t -= 1 }, 1000)</script>
</body>
</html>`
function longCode(lines: number): string {
return Array.from(
{ length: lines },
(_, i) => `export function helper${i}(value: number) { return value * ${i} }`
).join('\n')
}
describe('detectArtifact', () => {
it('promotes a full html document', () => {
const detection = detectArtifact('html', HTML_DOC)
expect(detection).not.toBeNull()
expect(detection?.kind).toBe('html')
expect(detection?.title).toBe('Pomodoro Timer')
})
it('falls back to h1 when the document has no title tag', () => {
const doc = `<!doctype html><html><body><h1>Budget <em>Dashboard</em></h1>${'<div>x</div>'.repeat(30)}</body></html>`
expect(detectArtifact('html', doc)?.title).toBe('Budget Dashboard')
})
it('ignores a small html snippet', () => {
expect(detectArtifact('html', '<div class="chip">hello</div>')).toBeNull()
})
it('ignores small svg fences (inline embed owns them)', () => {
expect(detectArtifact('svg', '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>')).toBeNull()
})
it('promotes a large svg', () => {
const svg = `<svg viewBox="0 0 100 100"><title>Org Chart</title>${'<rect x="1" y="2" width="3" height="4"/>'.repeat(80)}</svg>`
const detection = detectArtifact('svg', svg)
expect(detection?.kind).toBe('svg')
expect(detection?.title).toBe('Org Chart')
})
it('keeps short code inline', () => {
expect(detectArtifact('python', 'print("hi")')).toBeNull()
})
it('promotes long code and derives a declaration title', () => {
const code = `export function buildDashboard(config: Config) {\n${longCode(60)}\n}`
const detection = detectArtifact('typescript', code)
expect(detection?.kind).toBe('code')
expect(detection?.title).toBe('buildDashboard')
})
it('prefers a filename comment for the title', () => {
const code = `# server.py\n${longCode(60)
.replace(/export function/g, 'def')
.replace(/\{|\}/g, '')}`
const detection = detectArtifact('python', code)
expect(detection?.kind).toBe('code')
expect(detection?.title).toBe('server.py')
})
it('never promotes prose-ish or terminal fences', () => {
expect(detectArtifact('text', longCode(80))).toBeNull()
expect(detectArtifact('diff', longCode(80))).toBeNull()
expect(detectArtifact('markdown', longCode(80))).toBeNull()
expect(detectArtifact('mermaid', longCode(80))).toBeNull()
})
})
describe('artifactSlug', () => {
it('is stable across regenerations of the same artifact', () => {
const a = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
const b = artifactSlug({ kind: 'html', language: 'html', title: 'Pomodoro Timer' })
expect(a).toBe(b)
expect(a).toContain('html')
})
it('distinguishes different titles', () => {
expect(artifactSlug({ kind: 'html', language: 'html', title: 'Timer' })).not.toBe(
artifactSlug({ kind: 'html', language: 'html', title: 'Dashboard' })
)
})
it('handles empty/symbol-only titles', () => {
expect(artifactSlug({ kind: 'code', language: 'ts', title: '!!!' })).toBe('code:ts:untitled')
})
})
describe('artifactContentHash', () => {
it('is deterministic and content-sensitive', () => {
expect(artifactContentHash('abc')).toBe(artifactContentHash('abc'))
expect(artifactContentHash('abc')).not.toBe(artifactContentHash('abd'))
})
})
describe('artifactDownloadName', () => {
it('keeps an existing extension', () => {
expect(artifactDownloadName('code', 'python', 'server.py')).toBe('server.py')
})
it('appends by kind and language', () => {
expect(artifactDownloadName('html', 'html', 'Pomodoro Timer')).toBe('Pomodoro-Timer.html')
expect(artifactDownloadName('svg', 'svg', 'Org Chart')).toBe('Org-Chart.svg')
expect(artifactDownloadName('code', 'typescript', 'buildDashboard')).toBe('buildDashboard.ts')
expect(artifactDownloadName('code', 'unknownlang', '')).toBe('artifact.txt')
})
})

View file

@ -0,0 +1,232 @@
import { isLikelyProseCodeBlock, sanitizeLanguageTag } from '@/lib/markdown-code'
/**
* Artifact detection decides when a fenced block in an assistant message is
* substantial, self-contained content that deserves an artifact card (opening
* in the right rail) instead of an inline code block.
*
* Pure and cheap: it runs per streaming delta on the growing fence body, so
* everything here is a bounded regex scan or a line count. No store access.
*/
export type ArtifactKind = 'code' | 'html' | 'svg'
export interface ArtifactDetection {
kind: ArtifactKind
/** Sanitized fence language ('' possible for html/svg detected by shape). */
language: string
/** Human title derived from the content (html <title>, svg <title>, a named
* declaration for code). Falls back to a kind/language label. */
title: string
}
// A fence only becomes an artifact once it is unambiguously a document (html),
// a large standalone graphic (svg), or long enough that inlining it would
// drown the conversation (code). Small snippets stay inline code cards.
const HTML_DOC_RE = /<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/i
const HTML_TAG_RE = /<[a-z][a-z0-9-]*(\s[^>]*)?>/i
const HTML_DOC_MIN_CHARS = 160
const HTML_FRAGMENT_MIN_CHARS = 1200
const SVG_MIN_CHARS = 2000
const CODE_MIN_LINES = 48
const CODE_MIN_CHARS = 3000
const HTML_LANGUAGES = new Set(['html', 'htm', 'xhtml'])
// Languages whose fences are never artifacts: prose-ish, terminal output, and
// the fences already owned by richer renderers (mermaid diagrams, small svg).
const NON_ARTIFACT_LANGUAGES = new Set([
'',
'console',
'diff',
'log',
'logs',
'markdown',
'md',
'mermaid',
'output',
'patch',
'plain',
'plaintext',
'shell-session',
'stdout',
'text',
'txt'
])
function countLines(text: string): number {
let lines = 1
let index = text.indexOf('\n')
while (index !== -1) {
lines += 1
index = text.indexOf('\n', index + 1)
}
return lines
}
function stripTags(value: string): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function titleFromTag(content: string, tag: 'h1' | 'title'): string {
const match = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i').exec(content)
return match ? stripTags(match[1] || '').slice(0, 80) : ''
}
const CODE_DECLARATION_RE =
/(?:^|\n)\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|struct|interface|enum|trait|impl|def|fn)\s+([A-Za-z_$][\w$]*)/
// `// app.py`, `# server.ts`, `<!-- index.html -->`, `/* main.rs */` on the
// first meaningful line — the de-facto LLM convention for naming a file.
const FILENAME_COMMENT_RE = /^\s*(?:\/\/|#|--|<!--|\/\*)\s*([\w./-]+\.[a-z0-9]{1,8})\b/i
function codeTitle(language: string, content: string): string {
const head = content.slice(0, 2000)
const fileName = FILENAME_COMMENT_RE.exec(head)?.[1]
if (fileName) {
return fileName
}
const declaration = CODE_DECLARATION_RE.exec(head)?.[1]
if (declaration) {
return declaration
}
return language
}
export function detectArtifact(language: string | undefined, code: string | undefined): ArtifactDetection | null {
const trimmed = (code ?? '').trim()
if (!trimmed) {
return null
}
const clean = sanitizeLanguageTag(language || '')
if (HTML_LANGUAGES.has(clean)) {
const isDocument = HTML_DOC_RE.test(trimmed)
if (
(isDocument && trimmed.length >= HTML_DOC_MIN_CHARS) ||
(!isDocument && trimmed.length >= HTML_FRAGMENT_MIN_CHARS && HTML_TAG_RE.test(trimmed))
) {
return {
kind: 'html',
language: clean,
title: titleFromTag(trimmed, 'title') || titleFromTag(trimmed, 'h1') || 'HTML'
}
}
return null
}
if (clean === 'svg') {
// Small svg fences keep their inline embed (svg-embed.tsx); only a large
// standalone graphic graduates into an artifact tab.
if (trimmed.length >= SVG_MIN_CHARS && /<svg[\s>]/i.test(trimmed)) {
return { kind: 'svg', language: clean, title: titleFromTag(trimmed, 'title') || 'SVG' }
}
return null
}
if (NON_ARTIFACT_LANGUAGES.has(clean)) {
return null
}
if (trimmed.length < CODE_MIN_CHARS && countLines(trimmed) < CODE_MIN_LINES) {
return null
}
if (isLikelyProseCodeBlock(clean, trimmed)) {
return null
}
return { kind: 'code', language: clean, title: codeTitle(clean, trimmed) }
}
/** Stable identity slug for versioning: the same (kind, title, language) in a
* session is treated as one artifact the model iterates on. */
export function artifactSlug(detection: Pick<ArtifactDetection, 'kind' | 'language' | 'title'>): string {
const title = detection.title
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
return `${detection.kind}:${detection.language}:${title || 'untitled'}`
}
/** Tiny non-cryptographic content hash (FNV-1a) for version dedupe. */
export function artifactContentHash(content: string): string {
let hash = 0x811c9dc5
for (let i = 0; i < content.length; i += 1) {
hash ^= content.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record<string, string> = {
bash: '.sh',
c: '.c',
cpp: '.cpp',
csharp: '.cs',
css: '.css',
go: '.go',
htm: '.html',
html: '.html',
java: '.java',
javascript: '.js',
js: '.js',
json: '.json',
jsx: '.jsx',
kotlin: '.kt',
php: '.php',
py: '.py',
python: '.py',
rb: '.rb',
rs: '.rs',
ruby: '.rb',
rust: '.rs',
sh: '.sh',
sql: '.sql',
svg: '.svg',
swift: '.swift',
toml: '.toml',
ts: '.ts',
tsx: '.tsx',
typescript: '.ts',
xhtml: '.html',
xml: '.xml',
yaml: '.yaml',
yml: '.yaml'
}
export function artifactDownloadName(kind: ArtifactKind, language: string, title: string): string {
const base =
title
.replace(/[^\p{L}\p{N}._ -]+/gu, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'artifact'
if (/\.[a-z0-9]{1,8}$/i.test(base)) {
return base
}
const ext = kind === 'html' ? '.html' : kind === 'svg' ? '.svg' : DOWNLOAD_EXTENSION_BY_LANGUAGE[language] || '.txt'
return `${base}${ext}`
}

View file

@ -21,6 +21,9 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/version')).toBe(true)
expect(isDesktopSlashSuggestion('/yolo')).toBe(true)
expect(isDesktopSlashCommand('/yolo')).toBe(true)
expect(isDesktopSlashSuggestion('/approvals')).toBe(true)
expect(isDesktopSlashCommand('/approvals')).toBe(true)
expect(resolveDesktopCommand('/approvals')?.surface).toEqual({ kind: 'exec' })
})
it('surfaces skill and quick commands (extensions) in suggestions and lets them run', () => {

View file

@ -190,6 +190,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// them, /steer falls back to a next-turn prompt, and /usage is a formatted
// live report. Keep them on slash.exec until their RPC contracts are fully
// equivalent.
{
name: '/approvals',
description: 'Show or set approval mode [manual|smart|off]',
surface: exec(),
args: true
},
{
name: '/agents',
description: 'Show active desktop sessions and running tasks',

View file

@ -0,0 +1,16 @@
/** Save generated text content to a file via a blob download. Electron's
* default will-download behavior shows the OS save dialog, so this works
* without a dedicated IPC handler (same pattern as use-image-download's
* browser fallback). */
export function downloadTextFile(name: string, content: string, mimeType = 'text/plain') {
const blobUrl = URL.createObjectURL(new Blob([content], { type: `${mimeType};charset=utf-8` }))
const link = document.createElement('a')
link.href = blobUrl
link.download = name
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
}

View file

@ -0,0 +1,30 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with ui-tui/src/lib/model-search-text.ts,
* web/src/lib/model-search-text.ts, and hermes_cli/model_search.py.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ['kimi-k3', 'kimi']
}
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim()
if (!id) {
return model
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]
if (!aliases?.length) {
return id
}
return `${id} ${aliases.join(' ')}`
}

View file

@ -50,4 +50,34 @@ describe('flattenSessionsWithBranches', () => {
expect(flattenSessionsWithBranches([branch])).toEqual([{ session: branch }])
})
it('re-sorts roots by group recency by default (pinned-style jumps without preserveOrder)', () => {
// Stale important chat first in the caller's array; a recently-active
// background task second. Default path must lift the fresher root — that
// is what was scrambling the Pinned section before preserveOrder.
const important = session('important', { last_active: 10 })
const background = session('background', { last_active: 99 })
expect(flattenSessionsWithBranches([important, background]).map(e => e.session.id)).toEqual([
'background',
'important'
])
})
it("preserveOrder keeps the caller's root order even when activity is newer lower down", () => {
const important = session('important', { last_active: 10 })
const background = session('background', { last_active: 99 })
const branch = session('branch', { last_active: 50, parent_session_id: 'important' })
expect(
flattenSessionsWithBranches([important, background, branch], { preserveOrder: true }).map(e => ({
id: e.session.id,
stem: e.branchStem
}))
).toEqual([
{ id: 'important', stem: undefined },
{ id: 'branch', stem: '└─ ' },
{ id: 'background', stem: undefined }
])
})
})

View file

@ -5,10 +5,23 @@ export interface SidebarSessionEntry {
session: SessionInfo
}
export interface FlattenSessionsOptions {
/**
* Keep the input root order instead of re-sorting by group recency.
* Use for hand-ordered surfaces (pinned ids, manual recents drag) so a
* turn completing can't float a row. Branch children still nest under
* their parent; sibling branches stay ordered by their own recency.
*/
preserveOrder?: boolean
}
const recency = (session: SessionInfo): number => session.last_active || session.started_at || 0
/** Flat list with branch/fork sessions nested visually under their parent. */
export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): SidebarSessionEntry[] {
export function flattenSessionsWithBranches(
sessions: readonly SessionInfo[],
options: FlattenSessionsOptions = {}
): SidebarSessionEntry[] {
if (sessions.length < 2) {
return sessions.map(session => ({ session }))
}
@ -53,6 +66,7 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
// A group sorts by its freshest member, so activity on any branch lifts the
// whole parent→branches cluster together instead of stranding the parent at
// its own stale timestamp. Memoized — each subtree is folded at most once.
// Skipped when preserveOrder is set: the caller already chose positions.
const groupRecencyMemo = new Map<string, number>()
const groupRecency = (session: SessionInfo): number => {
@ -92,11 +106,13 @@ export function flattenSessionsWithBranches(sessions: readonly SessionInfo[]): S
children?.forEach((child, index) => emit(child, index === children.length - 1 ? '└─ ' : '├─ '))
}
sessions
.filter(session => !nestedIds.has(session.id))
.map((session, index) => ({ index, session }))
.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
.forEach(({ session }) => emit(session))
const roots = sessions.filter(session => !nestedIds.has(session.id)).map((session, index) => ({ index, session }))
if (!options.preserveOrder) {
roots.sort((a, b) => groupRecency(b.session) - groupRecency(a.session) || a.index - b.index)
}
roots.forEach(({ session }) => emit(session))
for (const session of sessions) {
if (!seen.has(session.id)) {

View file

@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { ArtifactDetection } from '@/lib/artifact-detect'
import {
$artifactRegistry,
$artifactTabs,
$artifactVersionSelection,
artifactsForSession,
artifactTabId,
clearArtifactRegistry,
closeArtifactTab,
getArtifact,
openArtifactTab,
selectArtifactVersion,
upsertArtifact
} from './artifacts'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import { $paneOpen } from './panes'
import { $activeSessionId, $selectedStoredSessionId } from './session'
const HTML_DETECTION: ArtifactDetection = { kind: 'html', language: 'html', title: 'Pomodoro Timer' }
describe('artifacts store', () => {
beforeEach(() => {
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearArtifactRegistry()
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
})
afterEach(() => {
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearArtifactRegistry()
window.localStorage.clear()
})
it('registers a new artifact with one version', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
expect(result?.versionAdded).toBe(true)
expect(artifactsForSession('session-1')).toHaveLength(1)
expect(getArtifact(result!.artifactId)?.versions).toHaveLength(1)
})
it('dedupes identical content by hash (streaming replays are no-ops)', () => {
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
const replay = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
expect(replay?.versionAdded).toBe(false)
expect(replay?.artifactId).toBe(first?.artifactId)
expect(getArtifact(first!.artifactId)?.versions).toHaveLength(1)
})
it('appends a version when the same artifact regenerates with new content', () => {
const first = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
const second = upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
expect(second?.versionAdded).toBe(true)
expect(second?.artifactId).toBe(first?.artifactId)
const record = getArtifact(first!.artifactId)
expect(record?.versions).toHaveLength(2)
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
expect(artifactsForSession('session-1')).toHaveLength(1)
})
it('keeps different titles as separate artifacts', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>timer</html>')
upsertArtifact('session-1', { ...HTML_DETECTION, title: 'Budget Dashboard' }, '<html>budget</html>')
expect(artifactsForSession('session-1')).toHaveLength(2)
})
it('scopes artifacts per session', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>a</html>')
upsertArtifact('session-2', HTML_DETECTION, '<html>b</html>')
expect(artifactsForSession('session-1')).toHaveLength(1)
expect(artifactsForSession('session-2')).toHaveLength(1)
})
it('persists the registry to localStorage', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>persisted</html>')
expect(window.localStorage.getItem('hermes.desktop.artifacts.v1')).toContain('persisted')
})
it('rejects empty sessions and empty content', () => {
expect(upsertArtifact('', HTML_DETECTION, '<html>x</html>')).toBeNull()
expect(upsertArtifact('session-1', HTML_DETECTION, ' ')).toBeNull()
})
it('opens and closes artifact tabs, driving the rail selection', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
const tabId = artifactTabId(result.artifactId)
openArtifactTab(result.artifactId)
expect($artifactTabs.get()).toEqual([tabId])
expect($rightRailActiveTabId.get()).toBe(tabId)
expect($paneOpen(PREVIEW_PANE_ID).get()).toBe(true)
closeArtifactTab(tabId)
expect($artifactTabs.get()).toEqual([])
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
})
it('does not duplicate a tab when the same artifact opens twice', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
openArtifactTab(result.artifactId)
openArtifactTab(result.artifactId)
expect($artifactTabs.get()).toHaveLength(1)
})
it('tracks version selection and snaps back to latest', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
upsertArtifact('session-1', HTML_DETECTION, '<html>v3</html>')
selectArtifactVersion(result.artifactId, 0)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
// Selecting the newest version clears the pin (absent = newest).
selectArtifactVersion(result.artifactId, 2)
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
// Out-of-range clamps.
selectArtifactVersion(result.artifactId, -5)
expect($artifactVersionSelection.get()[result.artifactId]).toBe(0)
})
it('reopening an artifact lands on the newest version', () => {
const result = upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')!
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
selectArtifactVersion(result.artifactId, 0)
openArtifactTab(result.artifactId)
expect(result.artifactId in $artifactVersionSelection.get()).toBe(false)
})
it('survives a registry reload round-trip', () => {
upsertArtifact('session-1', HTML_DETECTION, '<html>v1</html>')
upsertArtifact('session-1', HTML_DETECTION, '<html>v2</html>')
const raw = window.localStorage.getItem('hermes.desktop.artifacts.v1')!
const parsed = JSON.parse(raw) as Record<string, unknown[]>
expect(parsed['session-1']).toHaveLength(1)
// Simulate a fresh boot: hydrate a clean registry from the persisted JSON.
$artifactRegistry.set(JSON.parse(raw))
const record = artifactsForSession('session-1')[0]
expect(record?.versions).toHaveLength(2)
expect(record?.versions.at(-1)?.content).toBe('<html>v2</html>')
})
})

View file

@ -0,0 +1,386 @@
import { atom, computed } from 'nanostores'
import { artifactContentHash, type ArtifactDetection, type ArtifactKind, artifactSlug } from '@/lib/artifact-detect'
import { persistentAtom } from '@/lib/persisted'
import { $rightRailActiveTabId, PREVIEW_PANE_ID, RIGHT_RAIL_PREVIEW_TAB_ID, selectRightRailTab } from './layout'
import { setPaneOpen } from './panes'
import { $activeSessionId, $selectedStoredSessionId } from './session'
/**
* ARTIFACT REGISTRY substantial generated content (HTML pages, large SVGs,
* long code) produced in the transcript, promoted out of the message flow into
* versioned, openable artifacts. The renderer owns this state: artifacts are a
* presentation of message content the backend already persists, so the store
* is a cache keyed by session with bounded history.
*
* Identity: one artifact = one (session, slug) pair, where the slug derives
* from kind + language + title. When the model regenerates "the dashboard"
* three times in a session, that is ONE artifact with three versions, exactly
* like a document the user keeps refining not three cards.
*/
export interface ArtifactVersion {
content: string
createdAt: number
hash: string
}
export interface ArtifactRecord {
createdAt: number
id: string
kind: ArtifactKind
language: string
sessionId: string
slug: string
title: string
updatedAt: number
/** Oldest → newest. The last entry is the current version. */
versions: ArtifactVersion[]
}
type ArtifactRegistry = Record<string, ArtifactRecord[]>
const STORAGE_KEY = 'hermes.desktop.artifacts.v1'
const MAX_ARTIFACTS_PER_SESSION = 24
const MAX_VERSIONS_PER_ARTIFACT = 20
const MAX_SESSIONS = 40
// localStorage is ~5MB; artifacts carry full content, so cap the persisted
// bytes per artifact aggressively. Oversized artifacts survive in memory for
// the app's lifetime but persist only their newest version(s) that fit.
const MAX_PERSISTED_CHARS_PER_ARTIFACT = 120_000
export type ArtifactTabId = `artifact:${string}`
export function artifactTabId(artifactId: string): ArtifactTabId {
return `artifact:${artifactId}`
}
export function artifactIdFromTabId(tabId: string): string | null {
return tabId.startsWith('artifact:') ? tabId.slice('artifact:'.length) : null
}
function isArtifactVersion(value: unknown): value is ArtifactVersion {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return typeof r.content === 'string' && typeof r.createdAt === 'number' && typeof r.hash === 'string'
}
function isArtifactRecord(value: unknown): value is ArtifactRecord {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
typeof r.createdAt === 'number' &&
typeof r.id === 'string' &&
(r.kind === 'code' || r.kind === 'html' || r.kind === 'svg') &&
typeof r.language === 'string' &&
typeof r.sessionId === 'string' &&
typeof r.slug === 'string' &&
typeof r.title === 'string' &&
typeof r.updatedAt === 'number' &&
Array.isArray(r.versions) &&
r.versions.length > 0 &&
r.versions.every(isArtifactVersion)
)
}
function sanitizeRegistry(value: unknown): ArtifactRegistry {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const out: ArtifactRegistry = {}
for (const [sessionId, records] of Object.entries(value as Record<string, unknown>)) {
if (!Array.isArray(records)) {
continue
}
const valid = records.filter(isArtifactRecord)
if (valid.length > 0) {
out[sessionId] = valid
}
}
return out
}
function persistedVersions(record: ArtifactRecord): ArtifactVersion[] {
const kept: ArtifactVersion[] = []
let budget = MAX_PERSISTED_CHARS_PER_ARTIFACT
// Newest first; always keep at least the current version even if oversized.
for (let i = record.versions.length - 1; i >= 0; i -= 1) {
const version = record.versions[i]!
if (kept.length > 0 && version.content.length > budget) {
break
}
budget -= version.content.length
kept.unshift(version)
}
return kept
}
function pruneRegistry(registry: ArtifactRegistry): ArtifactRegistry {
const entries = Object.entries(registry)
.map(([sessionId, records]) => {
const trimmed = [...records]
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_ARTIFACTS_PER_SESSION)
.sort((a, b) => a.createdAt - b.createdAt)
return [sessionId, trimmed] as const
})
.filter(([, records]) => records.length > 0)
.sort(([, a], [, b]) => {
const latest = (records: readonly ArtifactRecord[]) => Math.max(...records.map(record => record.updatedAt))
return latest(b) - latest(a)
})
.slice(0, MAX_SESSIONS)
return Object.fromEntries(entries)
}
export const $artifactRegistry = persistentAtom<ArtifactRegistry>(
STORAGE_KEY,
{},
{
decode: raw => sanitizeRegistry(JSON.parse(raw) as unknown),
encode: registry =>
JSON.stringify(
Object.fromEntries(
Object.entries(pruneRegistry(registry)).map(([sessionId, records]) => [
sessionId,
records.map(record => ({ ...record, versions: persistedVersions(record) }))
])
)
)
}
)
/** Artifact tabs open in the right rail (ids into the registry). */
export const $artifactTabs = atom<ArtifactTabId[]>([])
/** Per-tab selected version index; absent = newest. Ephemeral by design: a
* reopened artifact always lands on its current version. */
export const $artifactVersionSelection = atom<Record<string, number>>({})
function currentArtifactSessionId(): string {
return $selectedStoredSessionId.get() || $activeSessionId.get() || ''
}
export function getArtifact(artifactId: string): ArtifactRecord | null {
for (const records of Object.values($artifactRegistry.get())) {
const found = records.find(record => record.id === artifactId)
if (found) {
return found
}
}
return null
}
export const $openArtifacts = computed([$artifactRegistry, $artifactTabs], (registry, tabs) => {
const byId = new Map<string, ArtifactRecord>()
for (const records of Object.values(registry)) {
for (const record of records) {
byId.set(record.id, record)
}
}
return tabs
.map(tabId => {
const id = artifactIdFromTabId(tabId)
const record = id ? byId.get(id) : undefined
return record ? { record, tabId } : null
})
.filter((entry): entry is { record: ArtifactRecord; tabId: ArtifactTabId } => entry !== null)
})
export function artifactsForSession(sessionId: string | null | undefined): ArtifactRecord[] {
const id = sessionId?.trim()
if (!id) {
return []
}
return $artifactRegistry.get()[id] ?? []
}
interface UpsertResult {
artifactId: string
record: ArtifactRecord
/** True when this call appended a NEW version (vs. deduped/no-op). */
versionAdded: boolean
}
/**
* Register (or version) an artifact for a session. Same slug + same content
* hash is a no-op (streaming remounts and transcript re-renders call this
* repeatedly); same slug + new content appends a version.
*/
export function upsertArtifact(
sessionId: string | null | undefined,
detection: ArtifactDetection,
content: string
): UpsertResult | null {
const id = sessionId?.trim()
const trimmed = content.trim()
if (!id || !trimmed) {
return null
}
const slug = artifactSlug(detection)
const hash = artifactContentHash(trimmed)
const registry = $artifactRegistry.get()
const records = registry[id] ?? []
const existing = records.find(record => record.slug === slug)
const now = Date.now()
if (existing) {
const known = existing.versions.some(version => version.hash === hash)
if (known) {
return { artifactId: existing.id, record: existing, versionAdded: false }
}
const versions = [...existing.versions, { content: trimmed, createdAt: now, hash }].slice(
-MAX_VERSIONS_PER_ARTIFACT
)
const next: ArtifactRecord = {
...existing,
// A regenerated artifact may carry a sharper title (html <title> arrives
// late in the stream); prefer the newest non-generic one.
title: detection.title || existing.title,
updatedAt: now,
versions
}
$artifactRegistry.set(
pruneRegistry({
...registry,
[id]: records.map(record => (record.id === existing.id ? next : record))
})
)
return { artifactId: existing.id, record: next, versionAdded: true }
}
const record: ArtifactRecord = {
createdAt: now,
id: `${id}:${slug}`,
kind: detection.kind,
language: detection.language,
sessionId: id,
slug,
title: detection.title,
updatedAt: now,
versions: [{ content: trimmed, createdAt: now, hash }]
}
$artifactRegistry.set(pruneRegistry({ ...registry, [id]: [...records, record] }))
return { artifactId: record.id, record, versionAdded: true }
}
export function upsertCurrentSessionArtifact(detection: ArtifactDetection, content: string): UpsertResult | null {
return upsertArtifact(currentArtifactSessionId(), detection, content)
}
/** Open an artifact tab in the right rail and select it. User-initiated only
* (card click) never called from streaming, per the no-hijack rule. */
export function openArtifactTab(artifactId: string) {
const tabId = artifactTabId(artifactId)
const current = $artifactTabs.get()
if (!current.includes(tabId)) {
$artifactTabs.set([...current, tabId])
}
// Land on the newest version whenever (re)opened.
const selection = $artifactVersionSelection.get()
if (artifactId in selection) {
const { [artifactId]: _dropped, ...rest } = selection
$artifactVersionSelection.set(rest)
}
setPaneOpen(PREVIEW_PANE_ID, true)
selectRightRailTab(tabId)
}
export function closeArtifactTab(tabId: ArtifactTabId): boolean {
const current = $artifactTabs.get()
const index = current.indexOf(tabId)
if (index === -1) {
return false
}
const next = current.filter(id => id !== tabId)
$artifactTabs.set(next)
const artifactId = artifactIdFromTabId(tabId)
if (artifactId) {
const { [artifactId]: _dropped, ...rest } = $artifactVersionSelection.get()
$artifactVersionSelection.set(rest)
}
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
return true
}
export function selectArtifactVersion(artifactId: string, versionIndex: number) {
const record = getArtifact(artifactId)
if (!record) {
return
}
const clamped = Math.max(0, Math.min(record.versions.length - 1, versionIndex))
const selection = $artifactVersionSelection.get()
if (clamped === record.versions.length - 1) {
if (artifactId in selection) {
const { [artifactId]: _dropped, ...rest } = selection
$artifactVersionSelection.set(rest)
}
return
}
$artifactVersionSelection.set({ ...selection, [artifactId]: clamped })
}
export function closeAllArtifactTabs() {
$artifactTabs.set([])
$artifactVersionSelection.set({})
}
export function clearArtifactRegistry() {
$artifactRegistry.set({})
closeAllArtifactTabs()
}

View file

@ -3,6 +3,7 @@ import { atom } from 'nanostores'
import { resetLiveRuntimeTracking } from '@/app/contrib/hooks/use-background-sync'
import { resetSidebarBatchCapability } from '@/hermes'
import { invalidateProfileScopedQueries } from '@/lib/query-client'
import { closeAllArtifactTabs } from '@/store/artifacts'
import { resetSessionsLimit } from '@/store/layout'
import {
$unreadFinishedSessionIds,
@ -61,6 +62,10 @@ export function wipeSessionListsForGatewaySwitch(): void {
setMessages([])
setFreshDraftReady(true)
// Artifact tabs reference sessions on the previous backend; the registry
// itself survives (it's local presentation state) but open tabs must not.
closeAllArtifactTabs()
// Narrowed: account/marketplace/onboarding caches are global, not gateway-
// scoped, so a mode swap must not refetch them.
invalidateProfileScopedQueries()

View file

@ -40,7 +40,7 @@ export const FILE_BROWSER_PANE_ID = 'file-browser'
export const PREVIEW_PANE_ID = 'preview'
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `artifact:${string}` | `file:${string}`
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })

View file

@ -3,6 +3,7 @@ import { atom, computed } from 'nanostores'
import { persistentAtom } from '@/lib/persisted'
import { normalize } from '@/lib/text'
import { $artifactTabs, type ArtifactTabId, closeAllArtifactTabs, closeArtifactTab } from './artifacts'
import {
$rightRailActiveTabId,
PREVIEW_PANE_ID,
@ -79,10 +80,12 @@ export const $filePreviewTabs = persistentAtom<FilePreviewTab[]>(TABS_STORAGE_KE
})
// Drop a restored active file-tab that didn't survive validation so the rail
// never points at a tab that isn't there.
// never points at a tab that isn't there. Artifact tabs are ephemeral (never
// restored), so a persisted `artifact:` active id is always stale too.
if (
$rightRailActiveTabId.get().startsWith('file:') &&
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())
($rightRailActiveTabId.get().startsWith('file:') &&
!$filePreviewTabs.get().some(tab => tab.id === $rightRailActiveTabId.get())) ||
$rightRailActiveTabId.get().startsWith('artifact:')
) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
@ -424,10 +427,10 @@ export function dismissPreviewTarget() {
$previewTarget.set(null)
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0)
setPaneOpen(PREVIEW_PANE_ID, $filePreviewTabs.get().length > 0 || $artifactTabs.get().length > 0)
}
function closeFilePreviewTab(tabId: RightRailTabId) {
@ -447,10 +450,12 @@ function closeFilePreviewTab(tabId: RightRailTabId) {
$filePreviewTabs.set(next)
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
selectRightRailTab(
next[Math.min(index, next.length - 1)]?.id ?? $artifactTabs.get()[0] ?? RIGHT_RAIL_PREVIEW_TAB_ID
)
}
if (next.length === 0 && !$previewTarget.get()) {
if (next.length === 0 && !$previewTarget.get() && $artifactTabs.get().length === 0) {
setPaneOpen(PREVIEW_PANE_ID, false)
}
}
@ -464,6 +469,16 @@ export function closeRightRailTab(tabId: RightRailTabId) {
return
}
if (tabId.startsWith('artifact:')) {
closeArtifactTab(tabId as ArtifactTabId)
if (!$previewTarget.get() && $filePreviewTabs.get().length === 0 && $artifactTabs.get().length === 0) {
setPaneOpen(PREVIEW_PANE_ID, false)
}
return
}
closeFilePreviewTab(tabId)
}
@ -474,7 +489,7 @@ export function closeActiveRightRailTab(): boolean {
let tabId = $rightRailActiveTabId.get()
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) {
const fallback = $filePreviewTabs.get()[0]?.id
const fallback = $filePreviewTabs.get()[0]?.id ?? $artifactTabs.get()[0]
if (!fallback) {
return false
@ -493,6 +508,16 @@ export function closeActiveRightRailTab(): boolean {
return true
}
if (tabId.startsWith('artifact:')) {
if (!$artifactTabs.get().includes(tabId as ArtifactTabId)) {
return false
}
closeRightRailTab(tabId)
return true
}
if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) {
return false
}
@ -503,7 +528,7 @@ export function closeActiveRightRailTab(): boolean {
}
// The rail's visible tab order: the live preview tab (when present) first, then
// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo
// the file tabs, then artifact tabs. Mirrors `ChatPreviewRail`'s `tabs` memo
// so "close others / to the right" act on what the user actually sees.
function rightRailTabOrder(): RightRailTabId[] {
const ids: RightRailTabId[] = []
@ -516,6 +541,10 @@ function rightRailTabOrder(): RightRailTabId[] {
ids.push(tab.id)
}
for (const tabId of $artifactTabs.get()) {
ids.push(tabId)
}
return ids
}
@ -544,13 +573,14 @@ export function closeRightRailTabsToRight(tabId: RightRailTabId) {
}
}
/** Dismisses the active preview + every file tab so the rail pane unmounts. */
/** Dismisses the active preview + every file and artifact tab so the rail pane unmounts. */
export function closeRightRail() {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
$filePreviewTabs.set([])
closeAllArtifactTabs()
setPaneOpen(PREVIEW_PANE_ID, false)
}

View file

@ -5,11 +5,16 @@ const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
// Items the bar hides until the user turns them on from its context menu. The
// bar's job is to answer "is the backend healthy, where am I, what's it doing" —
// route shortcuts (cron/webhooks/agents), the terminal toggle, and the approval
// pill are navigation, not status, so they start out of the way.
// pill are navigation, not status, so they start out of the way. The per-turn
// session readouts (running/session timers, context meter) are diagnostics most
// users don't watch, so they start hidden too and the bar stays quiet mid-turn.
export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
'agents',
'approval-mode',
'context-usage',
'cron',
'running-timer',
'session-timer',
'terminal',
'webhooks'
]

View file

@ -8,6 +8,7 @@ import {
clearSessionSubagents,
failedSubagentCount,
pruneDelegateFallbackSubagents,
pruneFinishedSessionSubagents,
upsertSubagent
} from './subagents'
@ -131,4 +132,62 @@ describe('subagent store', () => {
expect($subagentsBySession.get().s1).toBeUndefined()
expect($subagentsBySession.get().s2).toHaveLength(1)
})
// Regression test for #64015: still-RUNNING background subagents must survive
// the per-turn wipe that previously dropped them at message.start. The fix
// replaces clearSessionSubagents() with pruneFinishedSessionSubagents() at
// the use-message-stream message.start handler, so only terminal-status rows
// get filtered out.
it('pruneFinishedSessionSubagents keeps running/queued and drops terminal rows', () => {
upsertSubagent('s1', { goal: 'live-a', status: 'running', subagent_id: 'live-a', task_index: 0 })
upsertSubagent('s1', { goal: 'live-b', status: 'queued', subagent_id: 'live-b', task_index: 1 })
upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'done', task_index: 2 })
upsertSubagent('s1', { goal: 'broken', status: 'failed', subagent_id: 'broken', task_index: 3 })
upsertSubagent('s1', { goal: 'cancelled', status: 'interrupted', subagent_id: 'cancelled', task_index: 4 })
pruneFinishedSessionSubagents('s1')
const ids = listFor('s1')
.map(item => item.id)
.sort()
expect(ids).toEqual(['live-a', 'live-b'])
expect(activeSubagentCount(listFor('s1'))).toBe(2)
})
// Companion test: after prune, a late `subagent.complete` event for a
// surviving live row must still be accepted by upsertSubagent (the wipe
// path previously silently dropped these).
it('surviving live subagents still accept createIfMissing=false completion', () => {
upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'live', task_index: 0 })
pruneFinishedSessionSubagents('s1')
upsertSubagent(
's1',
{ status: 'completed', subagent_id: 'live', task_index: 0, summary: 'finished later' },
false,
'subagent.complete'
)
const item = listFor('s1')[0]
expect(item?.status).toBe('completed')
expect(item?.summary).toBe('finished later')
})
it('pruneFinishedSessionSubagents leaves other sessions untouched', () => {
upsertSubagent('s1', { goal: 'live', status: 'running', subagent_id: 'a', task_index: 0 })
upsertSubagent('s1', { goal: 'done', status: 'completed', subagent_id: 'b', task_index: 1 })
upsertSubagent('s2', { goal: 'live', status: 'running', subagent_id: 'c', task_index: 0 })
upsertSubagent('s2', { goal: 'done', status: 'completed', subagent_id: 'd', task_index: 1 })
pruneFinishedSessionSubagents('s1')
expect(listFor('s1').map(item => item.id)).toEqual(['a'])
expect(
listFor('s2')
.map(item => item.id)
.sort()
).toEqual(['c', 'd'])
})
})

View file

@ -189,6 +189,35 @@ export function clearSessionSubagents(sid: string) {
$subagentsBySession.set(rest)
}
/**
* Prune terminal-status subagent rows for a session, leaving running/queued
* entries untouched. Used at the `message.start` boundary in the desktop
* message-stream hook so that the *previous* turn's finished rows get flushed
* from the display while background subagents that outlived the spawning turn
* remain visible (and still accept late progress/complete events).
*
* Distinct from `clearSessionSubagents` (used by the Stop action, which
* genuinely cancels running subagents and so should drop them all) and from
* `pruneDelegateFallbackSubagents` (which filters by id prefix to remove
* placeholder rows once the real native event arrives).
*/
export function pruneFinishedSessionSubagents(sid: string) {
const map = $subagentsBySession.get()
const list = map[sid]
if (!list?.length) {
return
}
const next = list.filter(item => item.status === 'running' || item.status === 'queued')
if (next.length === list.length) {
return
}
$subagentsBySession.set({ ...map, [sid]: next })
}
export function pruneDelegateFallbackSubagents(sid: string) {
const map = $subagentsBySession.get()
const list = map[sid]

2
cli.py
View file

@ -9708,6 +9708,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
self._handle_footer_command(cmd_original)
elif canonical == "yolo":
self._toggle_yolo()
elif canonical == "approvals":
self._handle_approvals_command(cmd_original)
elif canonical == "reasoning":
self._handle_reasoning_command(cmd_original)
elif canonical == "fast":

View file

@ -0,0 +1 @@
kshitijk4poor

View file

@ -0,0 +1 @@
okalentiev

View file

@ -0,0 +1 @@
ruslanvasylev

View file

@ -0,0 +1 @@
knoal

View file

@ -287,6 +287,21 @@ if [ -d "$HERMES_HOME/cron" ]; then
chown_hermes_tree "$HERMES_HOME/cron"
fi
# Always ensure logs/gateways is hermes-owned (#45258). Formerly healed by
# restartable gateway log/run chown — removed due to symlink TOCTOU
# (CWE-59/367). The targeted data-volume chown above only runs when the
# top-level $HERMES_HOME is mis-owned, so a warm volume with hermes-owned
# HERMES_HOME but root-owned logs/gateways would otherwise leave
# s6-setuidgid hermes mkdir failing with Permission denied. Non-recursive:
# profile leaf dirs are each created/owned by their own log/run as hermes.
if [ -d "$HERMES_HOME/logs/gateways" ]; then
if refuse_symlinked_path "chown" "$HERMES_HOME/logs/gateways"; then
:
else
chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true
fi
fi
# Always reset ownership of pairing data on every boot, same docker-exec/
# root-write reason as profiles/ and cron/. `docker exec <container>
# hermes pairing approve …` defaults to uid=0 and writes 0600 root-owned

View file

@ -221,7 +221,11 @@ Subagent hooks describe delegated child-agent work:
and `child_goal`.
`subagent_stop` fields include parent/child session IDs, role/status fields,
`child_summary`, and `duration_ms`.
`child_summary`, `duration_ms`, and a metadata-only `tool_call_history`. Each
history entry contains the tool name, argument names, bounded side-effect
targets, input/output byte counts, and outcome. URL query strings and fragments
are removed; raw arguments, prompts, commands, contents, headers, and results
are intentionally excluded.
Observers can use these hooks to model nested trajectories while keeping child
agent execution linked to the parent turn that spawned it.

View file

@ -5975,7 +5975,54 @@ class APIServerAdapter(BasePlatformAdapter):
"timestamp": ts,
"text": preview or "",
})
# _thinking and subagent_progress are intentionally not forwarded
elif event_type in {"subagent.start", "subagent.complete"}:
event = {
"event": event_type,
"run_id": run_id,
"timestamp": ts,
}
if preview is not None:
event["preview"] = redact_sensitive_text(
str(preview), force=True
)
for key in (
"goal",
"task_count",
"task_index",
"subagent_id",
"child_session_id",
"parent_id",
"depth",
"model",
"tool_count",
"status",
"summary",
"duration_seconds",
"input_tokens",
"output_tokens",
"reasoning_tokens",
"api_calls",
"cost_usd",
"files_read",
"files_written",
"output_tail",
):
value = kwargs.get(key)
if value is None:
continue
# Free-text fields can carry child terminal/tool output —
# force the same secret redaction the API applies to error
# text before it leaves the process on a public stream.
if key in ("goal", "summary", "output_tail") and isinstance(
value, str
):
value = redact_sensitive_text(value, force=True)
event[key] = value
_push(event)
# _thinking, subagent.tool, and subagent_progress are intentionally
# not forwarded on the /v1/runs stream: they are high-volume UI
# noise. Lifecycle boundaries (start/complete) still need to land
# so clients can observe delegate_task timeouts and failures.
return _callback

View file

@ -2693,6 +2693,30 @@ class BasePlatformAdapter(ABC):
"""
return len
def max_message_length_for_chat(self, chat_id: str) -> int:
"""Per-chat max message length, in ``message_len_fn_for_chat`` units.
Default: the adapter-scalar ``MAX_MESSAGE_LENGTH`` (4096 when absent)
for a native adapter every chat lives on the same platform so the
scalar is already correct. The relay adapter overrides this: one relay
adapter fronts N platforms with different caps (Discord 2000 vs
Telegram 4096 vs Slack 39000), and the right cap depends on which
platform the chat's inbound arrived from.
"""
try:
return int(getattr(self, "MAX_MESSAGE_LENGTH", 4096) or 4096)
except (TypeError, ValueError):
return 4096
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]:
"""Per-chat length function (companion to max_message_length_for_chat).
Default: the adapter-wide ``message_len_fn``. The relay adapter
overrides it so a Telegram-fronted chat measures UTF-16 units while a
Discord-fronted chat on the same adapter measures codepoints.
"""
return self.message_len_fn
@property
def enforces_own_access_policy(self) -> bool:
"""Whether this adapter gates inbound access before dispatch.

View file

@ -20,7 +20,7 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict, Optional, cast
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult
@ -123,6 +123,42 @@ class RelayAdapter(BasePlatformAdapter):
def message_len_fn(self) -> Callable[[str], int]:
return _LEN_FNS.get(self.descriptor.len_unit, len)
# ── per-chat capability resolution (Phase 1.5 multi-platform) ─────────
def _descriptor_for_chat(self, chat_id: str) -> CapabilityDescriptor:
"""The capability descriptor governing a specific chat.
A multi-platform gateway fronts N platforms on ONE adapter, but the
scalar `descriptor`/`MAX_MESSAGE_LENGTH` surface can only carry one
platform's profile (the primary identity's). Platform caps genuinely
differ Discord 2000 / Telegram 4096 / Slack 39000 so applying the
primary's cap to every chat either fragments needlessly (small primary)
or over-sends into a platform 400 (large primary; the live bug: 2,543
and 2,641-char sends rejected by Discord). Resolve the chat's platform
from what we saw inbound (`_platform_by_chat`, the same map per-frame
egress uses) and look up that platform's negotiated descriptor on the
transport. Falls back to the scalar descriptor when the chat's platform
is unknown (never saw inbound) or the transport predates the map.
"""
platform = self._platform_by_chat.get(str(chat_id))
if platform and self._transport is not None:
resolve = getattr(self._transport, "descriptor_for_platform", None)
if callable(resolve):
try:
per_platform = cast(
Optional[CapabilityDescriptor], resolve(platform)
)
except Exception: # noqa: BLE001 - capability lookup must never break a send
per_platform = None
if per_platform is not None:
return per_platform
return self.descriptor
def max_message_length_for_chat(self, chat_id: str) -> int:
return self._descriptor_for_chat(chat_id).max_message_length
def message_len_fn_for_chat(self, chat_id: str) -> Callable[[str], int]:
return _LEN_FNS.get(self._descriptor_for_chat(chat_id).len_unit, len)
def supports_draft_streaming(
self,
chat_type: Optional[str] = None,

View file

@ -404,6 +404,11 @@ class WebSocketRelayTransport:
self._reader: Optional[asyncio.Task[None]] = None
self._inbound: Optional[InboundHandler] = None
self._descriptor: Optional[CapabilityDescriptor] = None
# Phase 1.5 multi-platform: descriptors keyed by the underlying platform
# (one per hello'd identity). `_descriptor` above stays the FIRST
# (primary-identity) descriptor for back-compat; this map is the
# per-platform capability surface read via `descriptor_for_platform`.
self._descriptors_by_platform: Dict[str, CapabilityDescriptor] = {}
self._descriptor_ready: asyncio.Future[CapabilityDescriptor] | None = None
# requestId -> future awaiting the matching outbound_result.
self._pending: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
@ -433,8 +438,10 @@ class WebSocketRelayTransport:
loop = asyncio.get_running_loop()
self._descriptor_ready = loop.create_future()
# A fresh handshake is coming; clear any stale descriptor so handshake()
# awaits the new one (matters on a re-dial).
# awaits the new one (matters on a re-dial). The per-platform map resets
# with it — a reconnected connector re-sends one descriptor per hello.
self._descriptor = None
self._descriptors_by_platform = {}
# scale-to-zero (D12): a successful (re-)dial ends any dormant state — we
# are live again, so a subsequent UNEXPECTED close should reconnect on the
# normal fast backoff, not the dormant cadence.
@ -520,6 +527,18 @@ class WebSocketRelayTransport:
raise RuntimeError("handshake() called before connect()")
return await asyncio.wait_for(self._descriptor_ready, timeout=self._connect_timeout_s)
def descriptor_for_platform(self, platform: str) -> Optional[CapabilityDescriptor]:
"""The negotiated descriptor for one fronted platform, or None.
Phase 1.5 multi-platform: the connector replies one descriptor per
hello'd identity; they accumulate here keyed by the descriptor's own
``platform`` field. Callers (RelayAdapter) use this to resolve PER-CHAT
capabilities e.g. Discord's 2000-char max_message_length vs
Telegram's 4096 — instead of applying the primary identity's scalar
descriptor to every platform this gateway fronts.
"""
return self._descriptors_by_platform.get(platform)
@property
def auth_revoked(self) -> bool:
"""True once the connector closed the socket with 4401 AFTER a prior
@ -795,7 +814,19 @@ class WebSocketRelayTransport:
ftype = frame.get("type")
if ftype == "descriptor":
descriptor = CapabilityDescriptor.from_json(json.dumps(frame.get("descriptor", {})))
self._descriptor = descriptor
# Phase 1.5 multi-platform: one descriptor frame arrives per hello'd
# identity. Accumulate them keyed by the descriptor's own platform so
# the adapter can resolve PER-CHAT capabilities (e.g. Discord's 2000
# vs Telegram's 4096 max_message_length) instead of collapsing N
# platforms onto whichever descriptor arrived last.
if descriptor.platform:
self._descriptors_by_platform[descriptor.platform] = descriptor
# The FIRST descriptor of this connection generation is the session
# default (the primary identity's) — later arrivals must NOT
# overwrite it, or the scalar capability surface silently becomes
# last-writer-wins across platforms.
if self._descriptor is None:
self._descriptor = descriptor
# Phase 7 Unit 7d-B: a received descriptor means the WS upgrade auth
# passed and the connector accepted us — record that we've handshaked
# at least once, so a LATER 4401 close is read as a revocation

View file

@ -11975,6 +11975,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "yolo":
return await self._handle_yolo_command(event)
if canonical == "approvals":
return await self._handle_approvals_command(event)
if canonical == "model":
return await self._handle_model_command(event)
@ -21018,6 +21021,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_raw_progress_limit = int(getattr(adapter, "MAX_MESSAGE_LENGTH", 4000) or 4000)
except Exception:
_raw_progress_limit = 4000
# Per-chat resolution (relay adapter fronting N platforms): the cap
# and length unit follow the chat's underlying platform. Native
# adapters return their scalar/property unchanged.
if isinstance(adapter, BasePlatformAdapter):
try:
_raw_progress_limit = int(
adapter.max_message_length_for_chat(source.chat_id) or 4000
)
_progress_len_fn = adapter.message_len_fn_for_chat(source.chat_id)
except Exception:
pass
# Leave a little room for platform quirks / formatting. For tiny
# test adapters keep the limit usable instead of clamping to 500+.
_PROGRESS_TEXT_LIMIT = max(

View file

@ -3648,6 +3648,23 @@ class GatewaySlashCommandsMixin:
return _apply_fast_selection(args, persist=persist_global)
async def _handle_approvals_command(self, event: MessageEvent) -> str:
"""Show or persist the profile-wide dangerous-command approval mode."""
from gateway.slash_access import policy_for_source
from hermes_cli.approval_mode import run_approval_mode_command
requested = event.get_command_args().strip() or None
# This mutates profile-wide security policy. The central slash gate can
# allow selected commands to non-admin users, so enforce admin again at
# this side-effect boundary. Unconfigured policies remain unrestricted.
policy = policy_for_source(self.config, event.source)
if requested and not policy.is_admin(event.source.user_id):
return "Only gateway admins can change the persistent approval mode."
result = run_approval_mode_command(requested)
# Approval checks load config dynamically; do not evict the cached agent
# or alter its system prompt/tool schema (prompt-cache prefix is sacred).
return result.message
async def _handle_yolo_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /yolo — toggle dangerous command approval bypass for this session only."""
from tools.approval import (

View file

@ -675,10 +675,13 @@ class GatewayStreamConsumer:
# Platform message length limit — leave room for cursor + formatting.
# Use the adapter's length function (e.g. utf16_len for Telegram) so
# overflow detection matches what the platform actually enforces.
# Both resolve PER-CHAT (max_message_length_for_chat): a relay adapter
# fronting N platforms has different caps per chat (Discord 2000 vs
# Telegram 4096); native adapters return their scalar unchanged.
# Gate on isinstance(BasePlatformAdapter) so test MagicMocks (whose
# auto-attributes return mock objects, not callables) fall back to len.
_len_fn: "Callable[[str], int]" = (
self.adapter.message_len_fn
self.adapter.message_len_fn_for_chat(self.chat_id)
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
@ -1359,6 +1362,15 @@ class GatewayStreamConsumer:
if isinstance(self.adapter, _BasePlatformAdapter)
else len
)
# Per-chat resolution (relay adapter fronting N platforms): the cap and
# length unit follow the chat's underlying platform, not the adapter
# scalar. Native adapters return their scalar/property unchanged.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
raw_limit = self.adapter.max_message_length_for_chat(self.chat_id)
_len_fn = self.adapter.message_len_fn_for_chat(self.chat_id)
except Exception as e:
logger.debug("per-chat limit resolution failed: %s", e)
safe_limit = max(500, raw_limit - 100)
chunks = self._split_text_chunks(continuation, safe_limit, len_fn=_len_fn)
@ -1744,8 +1756,11 @@ class GatewayStreamConsumer:
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
before the consumer splits an overflowing reply.
Adapters with a richer send/draft path (e.g. Telegram rich messages)
can raise this above ``MAX_MESSAGE_LENGTH`` via
Resolved PER-CHAT via ``max_message_length_for_chat`` a relay adapter
fronting N platforms has a different cap per chat (Discord 2000 vs
Telegram 4096 vs Slack 39000); native adapters return their scalar
``MAX_MESSAGE_LENGTH`` unchanged. Adapters with a richer send/draft
path (e.g. Telegram rich messages) can raise this above the base via
``streaming_overflow_limit`` so a reply that fits one rich message isn't
fragmented at the legacy edit limit. Falls back to
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
@ -1754,6 +1769,10 @@ class GatewayStreamConsumer:
# isinstance gate: MagicMock adapters return mock objects (truthy, not
# ints) for arbitrary attribute access — keep them on the base limit.
if isinstance(self.adapter, _BasePlatformAdapter):
try:
base = self.adapter.max_message_length_for_chat(self.chat_id)
except Exception as e:
logger.debug("max_message_length_for_chat failed: %s", e)
try:
cap = self.adapter.streaming_overflow_limit()
except Exception as e:

View file

@ -0,0 +1,87 @@
"""Shared persistent approval-mode command logic.
Approval mode is profile-scoped configuration, not conversation state. Changing
it affects subsequent terminal guard checks immediately because approval.py
loads config on each check; it must not rebuild a live agent or mutate its
system prompt/tool schema, preserving the prompt-cache prefix.
"""
from __future__ import annotations
from contextlib import redirect_stderr, redirect_stdout
from dataclasses import dataclass
from io import StringIO
from typing import Optional
VALID_APPROVAL_MODES = ("manual", "smart", "off")
@dataclass(frozen=True)
class ApprovalModeResult:
ok: bool
mode: str
changed: bool
message: str
def _effective_mode() -> str:
"""Return the exact mode enforced by the terminal approval guard."""
from tools.approval import _get_approval_mode
return _get_approval_mode()
def run_approval_mode_command(requested_mode: Optional[str]) -> ApprovalModeResult:
"""Inspect or persist ``approvals.mode`` through canonical config APIs."""
current = _effective_mode()
requested = (requested_mode or "").strip().lower()
if not requested:
return ApprovalModeResult(
True,
current,
False,
f"Approval mode: {current} (persistent profile setting).",
)
if requested not in VALID_APPROVAL_MODES:
return ApprovalModeResult(
False,
current,
False,
"Usage: /approvals [manual|smart|off]",
)
# set_config_value is the canonical managed-scope/write-safety chokepoint.
# It reports managed policy through stderr + SystemExit, so capture that for
# slash-command output instead of terminating the interactive worker.
from hermes_cli.config import set_config_value
output = StringIO()
try:
with redirect_stdout(output), redirect_stderr(output):
set_config_value("approvals.mode", requested)
except SystemExit:
detail = output.getvalue().strip() or "Approval mode is managed and cannot be changed."
return ApprovalModeResult(False, current, False, detail)
except Exception as exc:
return ApprovalModeResult(
False,
current,
False,
f"Failed to save approval mode: {exc}",
)
effective = _effective_mode()
if effective != requested:
return ApprovalModeResult(
False,
effective,
False,
f"Approval mode remains {effective}; the requested value did not become effective.",
)
return ApprovalModeResult(
True,
effective,
effective != current,
f"Approval mode: {effective} (persistent profile setting).",
)

View file

@ -7341,6 +7341,22 @@ def _prompt_model_selection(
desc_lines.append(f" ── {unavailable_footer} ──")
description = "\n".join(desc_lines) if desc_lines else None
# Search haystacks keep pricing labels visible while adding aliases
# for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi").
from hermes_cli.model_search import model_search_text
model_search_labels = []
for mid in ordered:
label = _label(mid)
haystack = model_search_text(mid)
# model_search_text always starts with the wire id; only append when
# aliases add tokens beyond the bare id already in the label.
model_search_labels.append(
label if haystack == mid else f"{label} {haystack}"
)
model_search_labels.append("Enter custom model name")
model_search_labels.append("Skip (keep current)")
idx = curses_radiolist(
"Select default model:",
choices,
@ -7348,6 +7364,7 @@ def _prompt_model_selection(
cancel_returns=-1,
description=description,
searchable=True,
search_labels=model_search_labels,
)
if idx < 0:
return None

View file

@ -2778,6 +2778,16 @@ class CLICommandsMixin:
except Exception:
pass
def _handle_approvals_command(self, cmd_original: str) -> None:
"""Show or persist the profile-wide dangerous-command approval mode."""
from cli import _cprint
from hermes_cli.approval_mode import run_approval_mode_command
parts = (cmd_original or "").strip().split(None, 1)
requested = parts[1] if len(parts) > 1 else None
result = run_approval_mode_command(requested)
_cprint(f" {result.message}")
def _handle_footer_command(self, cmd_original: str) -> None:
"""Toggle or inspect ``display.runtime_footer.enabled`` from the CLI.

View file

@ -165,6 +165,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("on", "off", "status")),
CommandDef("yolo", "Toggle YOLO mode (skip all dangerous command approvals)",
"Configuration"),
CommandDef("approvals", "Show or set the persistent dangerous-command approval mode",
"Configuration", args_hint="[manual|smart|off]",
subcommands=("manual", "smart", "off")),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide|full|clamp] [--global]",
subcommands=("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "show", "hide", "on", "off", "full", "clamp", "--global")),
@ -1187,10 +1190,15 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# /init clamps /version off the native list and breaks Telegram parity.
# - version: low-frequency info command; reachable as /hermes version on
# Slack. Demoted when /context claimed a native slot (context is a
# recurring inspection surface; version is a one-off lookup).
# recurring inspection surface; version is a one-off lookup); the demotion
# also absorbs the native slot /approvals now consumes at the 50-cap.
# - diff: git working-tree diff; reached via /hermes diff on Slack so it
# doesn't displace an existing native slash at the 50-command cap.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff"})
# - update: low-frequency self-update maintenance command; reached via
# /hermes update on Slack. Demoted to free the native slot /approvals now
# claims — without this entry /approvals tips the registry past the 50-cap
# and silently clamps /update off, breaking Telegram parity.
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init", "version", "diff", "update"})
def _sanitize_slack_name(raw: str) -> str:

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