fix(delegation): compression_threshold_tokens is opt-in (default 0); keep the value validation

Slimmed after review: the 200K default is dropped. Children compact at the same
0.50 x window ratio trigger as their parent (500K on a 1M model). Reasons:
- the run this came from happened at 0.85 (850K); main was already at 0.50, so
  the real delta against main was 500K -> 200K, not 850K -> 200K;
- a replay of the run's 22,489 logged calls (evals/postmortem, cap sweep) put
  200K-400K caps within 5% of each other in cost once cache prefixes are intact,
  because the write price dominates and the cap only trims read volume;
- every compaction is a chance to lose detail, and the accuracy side was never
  measured; at 500K a 1M child compacts roughly never.

What stays: the reviewer's finding that the value was coerced, not validated
(YAML true -> int 1 -> a one-token trigger; "200k" -> silently off). Values are
validated: int >= 16000 enables the cap, 0/false/null/unset = off, anything else
is warned and ignored. Docs and config comment restated accordingly.
This commit is contained in:
Teknium
2026-09-06 10:36:08 -07:00
351 changed files with 18008 additions and 1130 deletions
+9
View File
@@ -533,3 +533,12 @@ IMAGE_TOOLS_DEBUG=false
# GOOGLE_CHAT_ALLOW_ALL_USERS=false # Set true to skip the allowlist
# GOOGLE_CHAT_HOME_CHANNEL= # Default space (spaces/XXXX) for cron delivery
# GOOGLE_CHAT_HOME_CHANNEL_NAME= # Display name for the home channel
# =============================================================================
# reddit-reading skill (optional) — app-only credentials, NOT a user login
# =============================================================================
# The skill works with no credentials via Reddit's public feeds (~1 request/minute).
# For faster access with scores and nested comments, register a free "script" app
# at https://www.reddit.com/prefs/apps and paste its id and secret here.
# REDDIT_CLIENT_ID=
# REDDIT_CLIENT_SECRET=
+4
View File
@@ -564,6 +564,10 @@ _SESSION_STATE: Dict[str, Any] = {
# prefix, kept separately only to place an early cache marker.
"_cached_system_prompt": None,
"_cached_system_prompt_static": None,
# ``(cwd, workspace_block)`` pinned on the first build: the git/workspace snapshot is
# probed once per session and replayed on every rebuild, so a moving repo can't push the
# prefix-cache divergence point ahead of the volatile band at a compaction boundary.
"_frozen_workspace_snapshot": None,
# Whether close() also closes _session_db. False: a caller-supplied handle is usually the
# SHARED launch handle; callers handing over a DEDICATED handle set True.
"_owns_session_db": False,
+19
View File
@@ -72,6 +72,25 @@ def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) ->
)
_DEEPSEEK_THINKING_MODEL_PREFIXES = (
"deepseek-r", "deepseek-v4", "deepseek_v4", "deepseek-pro",
"deepseek_pro", "deepseek-flash", "deepseek_flash",
)
def _model_name_is_deepseek_thinking(model: str | None) -> bool:
"""Known DeepSeek thinking families behind an Anthropic-compatible relay.
Strip vendor namespaces, but do not treat arbitrary DeepSeek chat/distill
names as evidence of the thinking replay contract.
"""
if not isinstance(model, str):
return False
name = model.strip().lower().rsplit("/", 1)[-1]
return bool(name) and name.startswith(_DEEPSEEK_THINKING_MODEL_PREFIXES)
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
"""DeepSeek's ``/anthropic`` route. In thinking mode DeepSeek requires prior-turn ``thinking``
blocks to round-trip while the generic third-party path strips them; its blocks are unsigned,
+4 -2
View File
@@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional, Tuple
from agent.anthropic_endpoints import (
_is_deepseek_anthropic_endpoint, _is_kimi_family_endpoint, _is_nous_portal_endpoint,
_is_third_party_anthropic_endpoint,
_is_third_party_anthropic_endpoint, _model_name_is_deepseek_thinking,
)
logger = logging.getLogger(__name__)
@@ -565,7 +565,9 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
"""
is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url)
is_kimi = _is_kimi_family_endpoint(base_url, model)
is_deepseek = _is_deepseek_anthropic_endpoint(base_url)
is_deepseek = _is_deepseek_anthropic_endpoint(base_url) or (
is_third_party and _model_name_is_deepseek_thinking(model)
)
last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None)
for idx, m in _assistant_block_lists(result):
if is_kimi:
+3 -1
View File
@@ -2957,7 +2957,8 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool:
# Billing-body markers (credit exhaustion wrapped in 402/403/404/429 bodies), plus daily/weekly quota
# exhaustion (functionally credit exhaustion; "resource exhausted" is the Vertex/gRPC quota phrasing).
# exhaustion (functionally credit exhaustion; "resource exhausted" is the Vertex/gRPC quota phrasing —
# also serialized by SDK wrappers and NIM as RESOURCE_EXHAUSTED / ResourceExhausted / resource-exhausted).
_PAYMENT_KEYWORDS = (
"credits", "insufficient funds", "can only afford", "billing", "payment required",
"out of funds", "run out of funds", "balance_depleted", "no usable credits",
@@ -2965,6 +2966,7 @@ _PAYMENT_KEYWORDS = (
"requires a subscription", "upgrade for access", "upgrade for higher limits",
"reached your session usage limit", "quota exceeded", "quota_exceeded",
"too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted",
"resource_exhausted", "resource-exhausted", "resourceexhausted",
"weekly usage limit", "weekly limit",
)
+35 -11
View File
@@ -378,15 +378,24 @@ def _validated_openrouter_provider_sort(raw_sort: Any) -> Optional[str]:
def _provider_preferences_for_agent(agent) -> Dict[str, Any]:
"""Build the validated provider-routing object shared by request paths."""
preferences: Dict[str, Any] = {}
for key, value in (("only", agent.providers_allowed), ("ignore", agent.providers_ignored),
("order", agent.providers_order), ("sort", _validated_openrouter_provider_sort(agent.provider_sort)),
("require_parameters", True if agent.provider_require_parameters else None),
("data_collection", agent.provider_data_collection)):
if value:
preferences[key] = value
return preferences
"""Build the validated provider-routing object shared by request paths.
``provider_routing.models.<id>`` overlays the flat constructor values for the CURRENT
``agent.model`` (so ``/model`` switches, fallbacks, and delegated children on another
model each get their own pins without any surface re-plumbing the kwargs)."""
flat = {"only": agent.providers_allowed, "ignore": agent.providers_ignored, "order": agent.providers_order,
"sort": agent.provider_sort, "require_parameters": agent.provider_require_parameters,
"data_collection": agent.provider_data_collection}
per_model = {}
with contextlib.suppress(Exception):
from hermes_cli.config import load_config_readonly
from hermes_constants import resolve_per_model_provider_routing
_pr = load_config_readonly().get("provider_routing")
per_model = resolve_per_model_provider_routing(agent.model, (_pr or {}).get("models") if isinstance(_pr, dict) else None)
merged = {**flat, **{k: v for k, v in per_model.items() if k in flat}}
merged["sort"] = _validated_openrouter_provider_sort(merged["sort"])
merged["require_parameters"] = True if merged["require_parameters"] else None
return {key: value for key, value in merged.items() if value}
def _prompt_cache_scope_for_agent(agent) -> "str | None":
@@ -1720,6 +1729,19 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
value = getattr(assistant_message, attr, None)
if value:
msg[attr] = value
if attr == "codex_reasoning_items":
from agent.codex_responses_adapter import (
has_replayable_native_compaction_checkpoint,
)
note_checkpoint = getattr(
agent.context_compressor, "note_native_compaction_checkpoint", None
)
if (
callable(note_checkpoint)
and has_replayable_native_compaction_checkpoint(agent, [msg])
):
note_checkpoint()
if assistant_tool_calls:
msg["tool_calls"] = [_assistant_tool_call_dict(agent, tc, i) for i, tc in enumerate(assistant_tool_calls)]
@@ -1797,8 +1819,10 @@ def _fallback_reason_text(reason: "FailoverReason | None") -> str:
def _is_anthropic_wire_url(url: str) -> bool:
"""Same host match as determine_api_mode() / _detect_api_mode_for_url()."""
return url.rstrip("/").lower().endswith("/anthropic") or base_url_hostname(url) == "api.anthropic.com"
"""Same Messages-only host match as determine_api_mode() / _detect_api_mode_for_url(): api.anthropic.com,
a /anthropic suffix, or Kimi Code's api.kimi.com/coding (its /chat/completions 404s — #77256)."""
from hermes_cli.providers import host_mandated_api_mode
return host_mandated_api_mode(url) == "anthropic_messages"
def _fallback_api_mode_hint(fb: dict, fb_provider: str, fb_base_url_hint: Optional[str]) -> tuple[bool, str]:
+35 -11
View File
@@ -541,16 +541,10 @@ def classify_responses_route(agent: Any) -> ResponsesRouteFlags:
)
def estimate_native_responses_preflight_tokens(
agent: Any, messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None,
) -> Optional[int]:
"""Estimate tokens for the checkpoint-pruned Responses payload (the full transcript overstates a natively compacted
session and fires local compression needlessly). None when native compaction is not proven eligible or conversion fails.
Automatic preflight previously counted the full durable transcript. On a natively compacted Codex
session that overstates the wire by several times and fires local compression against history the main
request will never send (#96155).
"""
def _native_responses_replay_items(
agent: Any, messages: List[Dict[str, Any]]
) -> Optional[List[Dict[str, Any]]]:
"""Build the native-compaction-eligible wire items, or ``None`` when ineligible."""
if getattr(agent, "api_mode", None) != "codex_responses" or not isinstance(messages, list):
return None
route = classify_responses_route(agent)._asdict()
@@ -565,7 +559,37 @@ def estimate_native_responses_preflight_tokens(
native_compaction_eligible=True,
)
except Exception:
logger.debug("native Responses preflight conversion failed; falling back to generic estimate", exc_info=True)
logger.debug(
"native Responses replay conversion failed; using the generic fallback",
exc_info=True,
)
return None
return items
def has_replayable_native_compaction_checkpoint(
agent: Any, messages: List[Dict[str, Any]]
) -> bool:
"""Whether the current route would replay a persisted native checkpoint."""
items = _native_responses_replay_items(agent, messages)
if items is None:
return False
from agent.native_compaction import has_compaction_checkpoint
return has_compaction_checkpoint(items)
def estimate_native_responses_preflight_tokens(
agent: Any, messages: List[Dict[str, Any]], *, system_prompt: str = "", tools: Optional[List[Dict[str, Any]]] = None,
) -> Optional[int]:
"""Estimate tokens for the checkpoint-pruned Responses payload (the full transcript overstates a natively compacted
session and fires local compression needlessly). None when native compaction is not proven eligible or conversion fails.
Automatic preflight previously counted the full durable transcript. On a natively compacted Codex
session that overstates the wire by several times and fires local compression against history the main
request will never send (#96155).
"""
items = _native_responses_replay_items(agent, messages)
if items is None:
return None
from agent.model_metadata import estimate_request_tokens_rough
return estimate_request_tokens_rough(items, system_prompt=system_prompt or "", tools=tools)
+11 -7
View File
@@ -5,7 +5,8 @@ frozen :class:`RuntimeMode` built from a :class:`ContextProfile` (pure data). Th
system prompt reads ``system_prompt_parts()``; the toolset collapses ONLY under opt-in
``focus`` (never strips a user-enabled toolset). ``agent.coding_context``: ``auto``
(default, prompt-only) / ``focus`` / ``on`` / ``off``. Resolved once, immutable; the
workspace snapshot is never re-probed per turn (cache safety).
workspace snapshot is probed once per session and replayed through
``system_prompt_parts(workspace_block=...)`` on every later build (cache safety).
"""
from __future__ import annotations
@@ -322,13 +323,15 @@ class RuntimeMode:
return None
return [self.profile.toolset, *_enabled_mcp_servers(config)]
def system_prompt_parts(self, valid_tool_names=None) -> tuple[list[str], list[str], list[str]]:
def system_prompt_parts(self, valid_tool_names=None, workspace_block: Optional[str] = None) -> tuple[list[str], list[str], list[str]]:
"""Return (prefix, workspace, trailing) posture blocks in the historical flat order —
brief, snapshot, operator instructions — so prompt assembly can put a cache boundary
before the snapshot without changing persisted bytes. The brief carries the model-family
edit-format nudge (one cached string); ``valid_tool_names`` drops the ``todo_list``
sentence when that tool isn't loaded; operator instructions ride their own block so
the brief stays byte-stable."""
the brief stays byte-stable. ``workspace_block`` replays a snapshot the caller already
pinned at session start (``""`` = no workspace) instead of re-running the git probe;
``None`` probes."""
if not self.is_coding:
return [], [], []
prefix: list[str] = []
@@ -340,7 +343,7 @@ class RuntimeMode:
if family is not None:
brief = f"{brief}\n{_EDIT_FORMAT_GUIDANCE[family][1]}"
prefix.append(brief)
workspace = build_coding_workspace_block(self.cwd)
workspace = build_coding_workspace_block(self.cwd) if workspace_block is None else workspace_block
trailing = [f"Operator instructions (from config):\n{self.instructions}"] if self.instructions else []
return prefix, [workspace] if workspace else [], trailing
@@ -395,11 +398,12 @@ def coding_selection(*, platform: Optional[str] = None, cwd: Optional[str | Path
def coding_system_prompt_parts(
*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None,
model: Optional[str] = None, valid_tool_names=None,
model: Optional[str] = None, valid_tool_names=None, workspace_block: Optional[str] = None,
) -> tuple[list[str], list[str], list[str]]:
"""Return coding prefix, workspace snapshot, and trailing guidance."""
"""Return coding prefix, workspace snapshot, and trailing guidance. ``workspace_block``
replays the caller's pinned session-start snapshot instead of probing git again."""
mode = resolve_runtime_mode(platform=platform, cwd=cwd, config=config, model=model)
return mode.system_prompt_parts(valid_tool_names=valid_tool_names)
return mode.system_prompt_parts(valid_tool_names=valid_tool_names, workspace_block=workspace_block)
def coding_compact_skill_categories(*, platform: Optional[str] = None, cwd: Optional[str | Path] = None, config: Optional[dict[str, Any]] = None) -> frozenset[str]:
+16 -1
View File
@@ -2416,6 +2416,20 @@ class ContextCompressor(MicroCompactionMixin, ContextEngine):
except (TypeError, ValueError):
self._pending_request_rough_tokens = 0
def note_native_compaction_checkpoint(self) -> None:
"""Wait for real usage before trusting a newly checkpointed request.
Native Responses compaction replaces durable history with an opaque
encrypted checkpoint. Its serialized size is unrelated to the token
count billed by the provider, so the first rough estimate after capture
can jump by more than the whole context window. Reuse the one-response
compaction latch and discard any stale local-compression baseline; the
next provider response then pairs its real usage with the rough estimate
for the checkpointed request.
"""
self.awaiting_real_usage_after_compression = True
self.last_compression_rough_tokens = 0
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
"""Return True when a high rough preflight estimate is known-noisy.
Projects real usage as ``last_real + (rough_now - rough_at_last_real)`` and fires only when the
@@ -2426,7 +2440,8 @@ class ContextCompressor(MicroCompactionMixin, ContextEngine):
re-runs with the aligned basis."""
if rough_tokens < self.threshold_tokens:
return False
# After compaction last_real_prompt_tokens is STALE (above threshold); defer one turn until real usage arrives.
# After local or native compaction, last_real_prompt_tokens is STALE
# (above threshold); defer one turn until real usage arrives.
if self.awaiting_real_usage_after_compression:
return True
if self.last_real_prompt_tokens <= 0 or self.last_real_prompt_tokens >= self.threshold_tokens:
+57 -19
View File
@@ -195,7 +195,13 @@ async def preprocess_context_references_async(
# Expand concurrently (each ref is independent; several @url: refs would otherwise
# serialize web_extract round-trips). gather preserves order, so warnings/blocks
# are assembled in ref order; the token-budget check runs once afterwards.
tasks = (_expand_reference(ref, cwd_path, url_fetcher=url_fetcher, allowed_root=allowed_root_path) for ref in refs)
hard_limit = max(1, int(context_length * 0.50))
soft_limit = max(1, int(context_length * 0.25))
tasks = (
_expand_reference(ref, cwd_path, url_fetcher=url_fetcher, allowed_root=allowed_root_path,
max_inline_tokens=hard_limit)
for ref in refs
)
expanded = await asyncio.gather(*tasks)
warnings = [warning for warning, _ in expanded if warning]
blocks = [block for _, block in expanded if block]
@@ -204,8 +210,6 @@ async def preprocess_context_references_async(
message=message, original_message=message, references=refs, warnings=warnings, injected_tokens=injected_tokens
)
hard_limit = max(1, int(context_length * 0.50))
soft_limit = max(1, int(context_length * 0.25))
if injected_tokens > hard_limit:
warnings.append(f"@ context injection refused: {injected_tokens} tokens exceeds the 50% hard limit ({hard_limit}).")
result.blocked = True
@@ -235,11 +239,12 @@ _GIT_REFERENCE_ARGS: dict[str, Callable[[ContextReference], list[str]]] = {
async def _expand_reference(
ref: ContextReference, cwd: Path, *, url_fetcher: UrlFetcher = None, allowed_root: Path | None = None
ref: ContextReference, cwd: Path, *, url_fetcher: UrlFetcher = None, allowed_root: Path | None = None,
max_inline_tokens: int | None = None,
) -> Expansion:
try:
if ref.kind in ("file", "folder"):
return _expand_path_reference(ref, cwd, allowed_root=allowed_root)
return _expand_path_reference(ref, cwd, allowed_root=allowed_root, max_inline_tokens=max_inline_tokens)
if ref.kind in _GIT_REFERENCE_ARGS:
git_args = _GIT_REFERENCE_ARGS[ref.kind](ref)
return _expand_git_reference(ref, cwd, git_args, "git " + " ".join(git_args))
@@ -261,7 +266,8 @@ async def _expand_reference(
return f"{ref.raw}: unsupported reference type", None
def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Path | None = None) -> Expansion:
def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Path | None = None,
max_inline_tokens: int | None = None) -> Expansion:
"""``@file:`` / ``@folder:``: resolve, allow-check, then inline text / binary stub / listing."""
is_folder = ref.kind == "folder"
path = _resolve_path(cwd, ref.target, allowed_root=allowed_root)
@@ -281,7 +287,16 @@ def _expand_path_reference(ref: ContextReference, cwd: Path, *, allowed_root: Pa
if ref.line_start is not None:
text = "\n".join(text.splitlines()[max(ref.line_start - 1, 0):ref.line_end or ref.line_start])
lang = _FENCE_LANGUAGES.get(path.suffix.lower(), "")
return None, f"📄 {ref.raw} ({estimate_tokens_rough(text)} tokens)\n```{lang}\n{text}\n```"
text_tokens = estimate_tokens_rough(text)
# Check BEFORE building the fenced block: an oversized file is not going to be
# inlined, so don't build a second MB-scale string just to discard it.
if max_inline_tokens is not None and text_tokens > max_inline_tokens:
# One oversized file used to poison the aggregate check and refuse the whole
# turn (#61987); the file stays readable via the agent's tools instead. The
# block alone carries the message (same shape as the binary path) — a warning
# would duplicate it in "--- Context Warnings ---".
return None, _oversized_text_reference_block(ref, path, text_tokens)
return None, f"📄 {ref.raw} ({text_tokens} tokens)\n```{lang}\n{text}\n```"
def _run_quiet(cmd: list[str], cwd: Path, timeout: int, env: dict | None = None) -> subprocess.CompletedProcess:
@@ -425,12 +440,7 @@ def _iter_visible_entries(path: Path, cwd: Path, limit: int) -> list[Path]:
return output
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
try:
size = format_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
def _agent_visible_path(path: Path) -> str:
# Under a container backend the host path dangles inside the sandbox: translate staged
# files to their auto-mounted cache path; fall back to the host path (local backend /
# translation failure). Run the idempotent TERMINAL_ENV bridge first so in-process
@@ -439,14 +449,42 @@ def _binary_reference_block(ref: ContextReference, path: Path) -> str:
from tools.terminal_tool import _ensure_terminal_env_bridged
_ensure_terminal_env_bridged()
from tools.credential_files import to_agent_visible_cache_path
visible = to_agent_visible_cache_path(str(path))
return to_agent_visible_cache_path(str(path))
except Exception:
visible = str(path)
return str(path)
def _on_disk_reference_block(ref: ContextReference, path: Path, descriptor: str, reason: str, guidance: str) -> str:
"""Shared 📎 shape: the file was not inlined, but it IS on disk where the agent's
tools run — hand the model the path and a nudge instead of a dead-end warning."""
try:
size = format_bytes(path.stat().st_size)
except OSError:
size = "unknown size"
return (
f"📎 {ref.raw} ({mime}, {size}) — binary file, not inlined as text. "
f"It is available on disk at `{visible}`. Use your tools to work with it "
f"(read or convert it, extract its text, or view/render it as needed); "
f"do not tell the user the file type is unsupported."
f"📎 {ref.raw} ({descriptor}, {size}) — {reason} "
f"It is available on disk at `{_agent_visible_path(path)}`. {guidance}"
)
def _binary_reference_block(ref: ContextReference, path: Path) -> str:
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
return _on_disk_reference_block(
ref, path,
descriptor=mime,
reason="binary file, not inlined as text.",
guidance="Use your tools to work with it (read or convert it, extract its text, "
"or view/render it as needed); do not tell the user the file type is unsupported.",
)
def _oversized_text_reference_block(ref: ContextReference, path: Path, text_tokens: int) -> str:
return _on_disk_reference_block(
ref, path,
descriptor=f"text file, approximately {text_tokens} tokens",
reason="too large to inline safely.",
guidance="Use read_file with a narrow line range, search_files, or terminal/code tools "
"to inspect only the relevant parts; do not load the entire file into context.",
)
+42 -8
View File
@@ -791,7 +791,10 @@ def _borrowed_single_use_pool_root() -> Optional[Path]:
return None
def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global_path: Path) -> None:
def _update_root_pool_rows(
provider: str, payloads: List[Dict[str, Any]], global_path: Path,
*, status_cleared_ids: Optional[Iterable[str]] = None,
) -> None:
"""UPDATE-ONLY merge of *payloads* into the root store's rows for *provider*.
A borrower may refresh the root's rows (rotation, cooldown state) but
@@ -808,6 +811,7 @@ def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global
existing = pool.get(provider)
existing_list = existing if isinstance(existing, list) else []
incoming_by_id = {p.get("id"): p for p in payloads if isinstance(p, dict) and p.get("id")}
cleared = {cid for cid in (status_cleared_ids or ()) if cid}
merged: List[Dict[str, Any]] = []
changed = False
for disk_entry in existing_list:
@@ -816,7 +820,10 @@ def _update_root_pool_rows(provider: str, payloads: List[Dict[str, Any]], global
if incoming is None:
merged.append(disk_entry)
continue
updated = auth_mod._merge_disk_cooldown_state(incoming, disk_entry, provider)
# A deliberately cleared entry has no disk cooldown worth keeping.
updated = auth_mod._merge_disk_cooldown_state(
incoming, None if did in cleared else disk_entry, provider,
)
if updated != disk_entry:
changed = True
merged.append(updated)
@@ -830,6 +837,7 @@ def persist_pool_entries(
payloads: List[Dict[str, Any]],
*,
removed_ids: Optional[Iterable[str]] = None,
status_cleared_ids: Optional[Iterable[str]] = None,
) -> None:
"""Persist a provider's pool rows to the store that OWNS them.
@@ -845,7 +853,10 @@ def persist_pool_entries(
global_path = _borrowed_single_use_pool_root()
if global_path is not None:
try:
_update_root_pool_rows(provider, payloads, global_path)
_update_root_pool_rows(
provider, payloads, global_path,
status_cleared_ids=status_cleared_ids,
)
except Exception as exc:
# Fail closed on the FORK, not on the save: never fall back to
# writing a local copy (that IS the bug). The in-memory pool
@@ -856,7 +867,9 @@ def persist_pool_entries(
provider, exc,
)
return
write_credential_pool(provider, payloads, removed_ids=removed_ids)
write_credential_pool(
provider, payloads, removed_ids=removed_ids, status_cleared_ids=status_cleared_ids,
)
# --- Per-provider singleton refresh plumbing -------------------------------
@@ -1018,13 +1031,19 @@ class CredentialPool:
self._entries[idx] = new
return
def _persist(self, *, removed_ids: Optional[List[str]] = None) -> None:
def _persist(
self,
*,
removed_ids: Optional[List[str]] = None,
status_cleared_ids: Optional[List[str]] = None,
) -> None:
# Self-locking: snapshotting self._entries must not race a rotation.
with self._lock:
persist_pool_entries(
self.provider,
[entry.to_dict() for entry in self._entries],
removed_ids=removed_ids,
status_cleared_ids=status_cleared_ids,
)
def _adopt(self, entry: PooledCredential, *, persist: bool = True, **updates: Any) -> PooledCredential:
@@ -2139,14 +2158,29 @@ class CredentialPool:
return refreshed
def reset_statuses(self) -> int:
"""Clear exhaustion state on every entry. Returns how many were cleared.
``failure_reason`` lives in ``extra``, not a dataclass field, so it is
stripped explicitly. The persist declares the cleared ids because the
disk-recency merge reads a cleared ``last_status_at`` (None -> epoch 0)
as a stale snapshot and would copy a still-binding cooldown back.
"""
with self._lock:
stale = [e for e in self._entries if e.last_status or e.last_status_at or e.last_error_code]
stale = [
e for e in self._entries
if e.last_status or e.last_status_at or e.last_error_code or e.failure_reason
]
if stale:
stale_ids = {e.id for e in stale}
self._entries = [
replace(e, **_CLEAR_STATUS) if e.id in stale_ids else e for e in self._entries
replace(
e, **_CLEAR_STATUS,
extra={k: v for k, v in e.extra.items() if k != "failure_reason"},
)
if e.id in stale_ids else e
for e in self._entries
]
self._persist()
self._persist(status_cleared_ids=list(stale_ids))
return len(stale)
def remove_index(self, index: int) -> Optional[PooledCredential]:
+25 -1
View File
@@ -113,7 +113,8 @@ _BILLING_ERROR_CODES = frozenset({
# contains an overflow phrase; rate limit is matched first so throttle wins.
_RATE_LIMIT_PATTERNS = (
"rate limit", "rate_limit", "too many requests", "throttled", "requests per minute",
"tokens per minute", "requests per day", "try again in", "please retry after", "resource_exhausted",
"tokens per minute", "requests per day", "try again in", "please retry after",
"resource exhausted", "resource_exhausted", "resource-exhausted", "resourceexhausted",
"rate increased too quickly", "throttlingexception", "too many concurrent requests",
"servicequotaexceededexception", "throttling",
)
@@ -486,6 +487,13 @@ def _provider_special_cases(c: _Ctx) -> Optional[Verdict]:
# to format_error and a status-less block isn't left retryable (#18028).
if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS):
return _V_CONTENT_BLOCKED
# ChatGPT Codex masks a rejected encrypted-reasoning replay behind the same bare
# ``invalid_prompt: Request blocked.`` it uses for real blocks (#92353). Exact envelope
# + provider only. The verdict keeps format_error's abort-and-fallback hints; the one
# extra thing it buys is turn_recovery's replay strip, which still requires cached
# ``codex_reasoning_items`` — a genuine block with nothing to strip behaves as before.
if _is_codex_masked_replay_rejection(c):
return _v(_R.invalid_encrypted_content, **_ABORT_FALLBACK)
# Anthropic thinking-block 400s (signature mismatch after transcript
# mutation). Not gated on provider — OpenRouter proxies Anthropic errors.
if status == 400 and "thinking" in msg and any(p in msg for p in _THINKING_MUTATION_WORDS):
@@ -777,6 +785,22 @@ def _is_server_injected_param_rejection(error_msg: str, provider: str) -> bool:
return False
_CODEX_MASKED_REPLAY_MESSAGE = "request blocked."
def _is_codex_masked_replay_rejection(c: "_Ctx") -> bool:
"""HTTP 400 / status-less ``{code: invalid_prompt, message: "Request blocked."}`` from
``openai-codex`` — as an SDK error body, a Responses ``error`` SSE frame, or the
``response.failed`` text ``"invalid_prompt: Request blocked."``."""
if c.provider_slug != "openai-codex" or c.status_code not in (None, 400):
return False
# The OpenAI SDK unwraps ``body["error"]`` on status errors; stream frames keep the envelope.
body_msg = next((str(m).strip().lower() for m in _body_message_candidates(c.body or {}) if m), "")
return (c.code == "invalid_prompt" and body_msg == _CODEX_MASKED_REPLAY_MESSAGE) or (
c.msg.strip() == f"invalid_prompt: {_CODEX_MASKED_REPLAY_MESSAGE}"
)
def _error_obj(body: Any) -> dict:
"""``body["error"]`` when it is a dict, else ``{}``."""
err = body.get("error") if isinstance(body, dict) else None
+14 -1
View File
@@ -9,6 +9,7 @@ import threading
from typing import Optional
from agent.interrupt_compat import request_hard_interrupt
from tools.interrupt import request_yield as _request_yield
from tools.interrupt import set_interrupt as _set_interrupt
# Same logger name as the origin module so log records / caplog filters are unchanged.
@@ -245,8 +246,20 @@ class InterruptControlMixin:
return False
# Never kill a tool to deliver guidance; the steer drain puts it on the final tool result.
# A foreground terminal command would park that delivery until it exits (a 5-minute
# `sleep` poller, a build), so ask the tool workers to YIELD: terminal hands the live
# process to the background registry and returns; tools that don't yield are unaffected.
if getattr(self, "_executing_tools", False):
return self.steer(cleaned)
accepted = self.steer(cleaned)
if accepted:
tracker = getattr(self, "_tool_worker_threads", None)
tracker_lock = getattr(self, "_tool_worker_threads_lock", None)
if tracker is not None and tracker_lock is not None:
with tracker_lock:
worker_tids = list(tracker)
for tid in worker_tids:
_request_yield(tid)
return accepted
_model_active = getattr(self, "_model_request_active", None)
with _ic_lock(self, "_pending_redirect_lock"):
+6 -1
View File
@@ -328,7 +328,12 @@ def is_native_compaction_rejection(error: Any, status_code: Any = None) -> bool:
def has_compaction_checkpoint(items: Any) -> bool:
"""Does this ``codex_reasoning_items`` sidecar carry a compaction checkpoint? A checkpoint is
cumulative context living in exactly one place: rewrite/discard the sidecar only after asking."""
return isinstance(items, list) and any(_is_compaction_item(item) for item in items)
return isinstance(items, list) and any(
_is_compaction_item(item)
and isinstance(item.get("encrypted_content"), str)
and bool(item["encrypted_content"].strip())
for item in items
)
def merge_interim_reasoning_items(prior_items: Any, new_items: Any) -> List[Dict[str, Any]]:
+112
View File
@@ -0,0 +1,112 @@
"""Nous Portal ``anthropic/*`` wire selection when ``nous.anthropic_wire`` is ``auto``.
Portal serves Claude two ways and Hermes cannot tell which from the request: an OpenRouter
passthrough (today, for every ``anthropic/*`` id) or GMI/Vertex (planned once GMI is back). The
native Messages wire is the better transport, but on the OpenRouter path it re-writes the previous
turn's prompt cache on 14-20% of consecutive calls in concurrent tool loops (measured 2026-09-06;
NousResearch/api#227), so the session must ride chat/completions there. On GMI that is untested,
and until it is measured ``auto`` never promotes to native.
The upstream IS visible in the first RESPONSE: OpenRouter stamps ``provider`` (chat wire) and
mints ``gen-<unix>-<rand>`` ids; GMI/Vertex responses carry neither. So ``auto`` starts every
session on chat (safe on both upstreams), reads the first response, and switches the session to
native only when the upstream is GMI and native has been cleared for GMI. One decision per
session, at call 1, before there is a cache to lose; later calls never flip.
``classify_upstream`` is pure and unit-tested; ``maybe_switch_wire_after_first_response`` is
the single hook, called from the usage recorder.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Flip to True only after the 20x6 concurrency probe (evals/postmortem/live_ab) is clean on a
# GMI-served anthropic/* id on the native wire. Until then ``auto`` is chat everywhere.
GMI_NATIVE_WIRE_CLEARED = False
_OPENROUTER_ID = re.compile(r"^gen-\d{9,}-[A-Za-z0-9_-]{8,}$")
def classify_upstream(response: Any) -> Optional[str]:
"""``"openrouter"`` / ``"gmi"`` / ``None`` (unknown) from a Portal response object.
Works on both wires: the OpenAI SDK object exposes ``.provider`` (OpenRouter's upstream name,
e.g. ``"Anthropic"``, ``"Amazon Bedrock"``) and an OpenRouter-minted ``.id``; the Anthropic SDK
object has ``.id`` only. GMI/Vertex responses have Anthropic-native ``msg_…`` ids and no
``provider``. Anything else is unknown, and unknown never triggers a switch.
"""
if response is None:
return None
if isinstance(getattr(response, "provider", None), str) and getattr(response, "provider"):
return "openrouter"
rid = getattr(response, "id", None)
if isinstance(rid, str):
if _OPENROUTER_ID.match(rid):
return "openrouter"
if rid.startswith("msg_"):
return "gmi"
return None
def wire_for_upstream(upstream: Optional[str]) -> str:
"""The api_mode ``auto`` wants once the upstream is known. Chat unless GMI and cleared."""
if upstream == "gmi" and GMI_NATIVE_WIRE_CLEARED:
return "anthropic_messages"
return "chat_completions"
def maybe_switch_wire_after_first_response(agent: Any, response: Any, api_call_count: int) -> bool:
"""Decide the session's wire from its first response; the switch itself is applied at the
start of the next iteration (``apply_pending_wire_switch``), never while a response is being
consumed. Returns True when a switch was scheduled.
Only for provider=nous, anthropic/* models, ``nous.anthropic_wire: auto``, and only on the
session's first API call.
"""
if api_call_count != 1 or getattr(agent, "_nous_wire_decided", False):
return False
if (getattr(agent, "provider", "") or "").lower() != "nous":
return False
model = str(getattr(agent, "model", "") or "")
if not model.lower().startswith("anthropic/"):
return False
try:
from hermes_cli.providers import _nous_anthropic_wire
if _nous_anthropic_wire() != "auto":
return False
except Exception:
return False
agent._nous_wire_decided = True # one decision per session, whatever it is
upstream = classify_upstream(response)
want = wire_for_upstream(upstream)
if want == getattr(agent, "api_mode", None):
logger.debug("nous wire auto: upstream=%s, staying on %s", upstream, want)
return False
agent._nous_wire_pending = (want, upstream)
return True
def apply_pending_wire_switch(agent: Any) -> bool:
"""At iteration start, with no response in flight: perform the switch scheduled by
``maybe_switch_wire_after_first_response``. Reuses ``switch_model`` (same model/provider, new
api_mode) so client rebuild, cache policy and ``_primary_runtime`` stay consistent. A failure
is logged and the session stays on its current wire."""
pending = getattr(agent, "_nous_wire_pending", None)
if not pending:
return False
agent._nous_wire_pending = None
want, upstream = pending
try:
from agent.agent_runtime_helpers import switch_model
switch_model(agent, agent.model, "nous", api_key=getattr(agent, "api_key", "") or "",
base_url=getattr(agent, "base_url", "") or "", api_mode=want)
except Exception as exc: # never let wire selection break a turn
logger.warning("nous wire auto: switch to %s failed (%s); staying on %s", want, exc, agent.api_mode)
return False
logger.info("nous wire auto: upstream=%s -> %s for the rest of session %s", upstream, want,
getattr(agent, "session_id", "?"))
return True
+21 -18
View File
@@ -96,7 +96,15 @@ def _scan_context_content(content: str, filename: str) -> str:
def _find_git_root(start: Path) -> Optional[Path]:
"""Nearest ancestor (or *start* itself) containing ``.git``, else None."""
current = start.resolve()
return next((p for p in (current, *current.parents) if (p / ".git").exists()), None)
# A parent the process may not stat (locked-down /home on shared hosts) is "no .git here", not a crash.
return next((p for p in (current, *current.parents) if _exists_or_denied(p / ".git")), None)
def _exists_or_denied(path: Path) -> bool:
try:
return path.exists()
except OSError:
return False
def _find_hermes_md(cwd: Path) -> Optional[Path]:
@@ -845,13 +853,6 @@ def _tenv_read(name: str, default: str = "") -> str:
_BACKEND_IMAGE_KEYS = {b: f"{b}_image" for b in ("docker", "singularity", "modal", "daytona")}
# (config key, default) pairs forwarded to _create_environment's container_config.
_CONTAINER_CONFIG_DEFAULTS = (
("container_cpu", 1), ("container_memory", 5120), ("container_disk", 51200), ("container_persistent", True),
("modal_mode", "auto"), ("docker_volumes", []), ("docker_mount_cwd_to_workspace", False),
("docker_forward_env", []), ("docker_env", {}), ("docker_run_as_host_user", False), ("docker_extra_args", []),
("docker_shm_size", "1g"), ("docker_persist_across_processes", True), ("docker_shared_container_key", ""),
("docker_orphan_reaper", True),
)
# Single-line POSIX probe; `2>/dev/null` keeps a missing binary from polluting output.
_BACKEND_PROBE_CMD = (
"printf 'os=%s\\nkernel=%s\\nhome=%s\\ncwd=%s\\nuser=%s\\n' \"$(uname -s 2>/dev/null || echo unknown)\" "
@@ -862,31 +863,33 @@ _BACKEND_PROBE_CMD = (
def _run_backend_probe(env_type: str, terminal_tool) -> str:
"""Execute the probe command inside a freshly built backend; "" when it yields nothing."""
from tools.terminal_tool_backends import _create_environment, _ssh_config_from_config
from tools.terminal_tool_backends import _container_config_from_config, _create_environment, _ssh_config_from_config
from tools.terminal_tool_lifecycle import _cleanup_env
config = terminal_tool._get_env_config()
# Mirrors tools/terminal_tool.py's live-command assembly (`_create_environment` is the factory).
# Same container_config shaper as the live terminal path: a private copy of the key table here
# drifted (no docker_network) and gave the probe a bridge-networked container under lockdown.
env = _create_environment(
env_type=env_type, image=config.get(_BACKEND_IMAGE_KEYS[env_type], "") if env_type in _BACKEND_IMAGE_KEYS else "", cwd=config.get("cwd", ""),
timeout=config.get("timeout", 180),
ssh_config=_ssh_config_from_config(config) if env_type == "ssh" else None,
container_config=({k: config.get(k, d) for k, d in _CONTAINER_CONFIG_DEFAULTS}
container_config=(_container_config_from_config(config)
if terminal_tool._is_container_backend(env_type) else None),
task_id="prompt-backend-probe", host_cwd=config.get("host_cwd"),
# Only ssh honors this: an isolated ControlMaster socket and no remote dir setup / file sync /
# snapshot. A normal SSHEnvironment would upload the whole ~/.hermes tree just to run `uname`,
# and its later __del__ would sync_back() and close the master shared with the agent's own env.
probe_only=True,
)
try:
result = env.execute(_BACKEND_PROBE_CMD, timeout=4)
finally:
# One-shot `uname`; without teardown the backend leaves a second idle sandbox
# (task_id="prompt-backend-probe") running for the whole process next to the agent's own.
# ssh is left alone: no task-scoped sandbox, and its cleanup() closes a ControlMaster socket
# (keyed by user@host:port) shared with the agent's real environment; ControlPersist expires it.
if env_type != "ssh":
try:
_cleanup_env(env, force_remove=True)
except Exception:
logger.debug("Backend probe cleanup failed", exc_info=True)
try:
_cleanup_env(env, force_remove=True)
except Exception:
logger.debug("Backend probe cleanup failed", exc_info=True)
if result.get("returncode") != 0:
logger.debug("Backend probe returned non-zero: %r", result)
return ""
+16
View File
@@ -4,6 +4,7 @@ only (notification, timeout, kill and retry policy live elsewhere). Provenance i
from __future__ import annotations
import sys
import time
from contextlib import suppress
from enum import Enum
@@ -45,6 +46,21 @@ def normalize_activity_provenance(provenance: Optional[ActivityProvenance | str]
return ActivityProvenance.UNKNOWN
def format_iteration_progress(api_call_count: Any, max_iterations: Any) -> str:
"""``iteration N/M`` for user-facing status lines, or ``iteration N`` when the cap is unbounded.
``AIAgent.max_iterations`` defaults to ``sys.maxsize`` (unlimited), so printing the pair verbatim
shows ``iteration 3/9223372036854775807`` in busy acks, heartbeats and timeout diagnostics (#102806).
"""
try:
cap = int(max_iterations)
except (TypeError, ValueError):
cap = sys.maxsize
if cap >= sys.maxsize:
return f"iteration {api_call_count}"
return f"iteration {api_call_count}/{cap}"
def reset_session_activity_persist_window(agent: Any) -> None:
"""Clear the persist rate-limit so the next stamp writes through (terminal compression labels must not stick on mid-compress text)."""
with suppress(Exception):
+20 -4
View File
@@ -538,12 +538,28 @@ def _alibaba_identity_part(agent: Any) -> List[str]:
def _coding_parts(agent: Any) -> Tuple[List[str], List[str], List[str]]:
"""``(prefix, workspace, trailing)`` coding-posture blocks; all empty
without tools or when probing fails (it must never block prompt build)."""
without tools or when probing fails (it must never block prompt build).
The workspace block is a live git probe that leads the context tier, ahead of the whole
volatile band; re-probing at the compaction rebuild re-emits different bytes for any
repo that moved and defeats the keep-prompt fast path. So the bytes are pinned per
session on the agent, keyed by the resolved cwd (a gateway serves many cwds), and
replayed on rebuilds; ``reset_session_state`` drops the pin at a session boundary.
"""
try:
from agent.coding_context import coding_system_prompt_parts
if agent.valid_tool_names:
return coding_system_prompt_parts(platform=agent.platform, cwd=resolve_context_cwd(),
model=agent.model, valid_tool_names=agent.valid_tool_names)
if not agent.valid_tool_names:
return [], [], []
cwd = resolve_context_cwd()
cwd_key = str(cwd) if cwd is not None else ""
pinned = getattr(agent, "_frozen_workspace_snapshot", None)
# "" is a real pinned value (no workspace here) — only a cwd mismatch re-probes.
replay = pinned[1] if pinned is not None and pinned[0] == cwd_key else None
parts = coding_system_prompt_parts(platform=agent.platform, cwd=cwd, model=agent.model,
valid_tool_names=agent.valid_tool_names, workspace_block=replay)
if replay is None:
agent._frozen_workspace_snapshot = (cwd_key, parts[1][0] if parts[1] else "")
return parts
except Exception:
pass
return [], [], []
+3 -6
View File
@@ -210,17 +210,14 @@ def _do_upload(jsonl: str, *, token: str, session_id: str, dataset_name: str = D
def load_session_messages(session_id: str, db_path=None) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""``(messages, meta)`` from SQLite; ``meta`` is ``{}`` when the session row is missing (a live, untitled
session may still have messages)."""
from hermes_state import SessionDB
db = SessionDB(db_path=db_path) if db_path else SessionDB()
from hermes_state_registry import acquire, release_or_close
db = acquire(db_path or None)
try:
resolved = db.resolve_session_id(session_id) or session_id
meta = db.get_session(resolved) or {}
return db.get_messages_as_conversation(resolved), meta
finally:
try:
db.close()
except Exception:
logger.debug("Failed to close trace-upload SessionDB", exc_info=True)
release_or_close(db)
def upload_session_trace(
+4 -2
View File
@@ -146,8 +146,10 @@ def _build_gemini_thinking_config(model: str, reasoning_config: dict | None) ->
return thinking_config
if effort not in {"minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
effort = "medium"
# Gemini 3 Flash documents low/medium/high; Gemini 3 Pro only low/high.
if normalized_model.startswith(("gemini-3", "gemini-3.1")):
# Gemini 3 Flash documents low/medium/high thinking levels; Gemini 3 Pro
# is stricter (low/high). Clamp Hermes' wider effort set to what each
# family accepts so we never forward an undocumented level verbatim.
if normalized_model.startswith("gemini-3"):
if "flash" in normalized_model:
thinking_config["thinkingLevel"] = (
"low" if effort in {"minimal", "low"} else "high" if effort in _HIGH_EFFORTS else "medium"
+26 -2
View File
@@ -525,13 +525,37 @@ def _stage_turn_user_message(
def _hydrate_from_history(agent: Any, conversation_history: Optional[List[Any]]) -> None:
"""Hydrate the todo store and per-session nudge counters from persisted history."""
"""Hydrate process-local state from persisted history on the first resumed turn."""
if not conversation_history:
return
if not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history.
# A live native checkpoint arms this latch while its response is captured. A
# restarted agent must recover the same one-response deferral before turn-start
# compression can rewrite the restored opaque checkpoint. Reuse the adapter's
# exact route/issuer/replay filtering and tolerate plugin compressors without the
# optional hook.
if agent._user_turn_count == 0:
note_checkpoint = getattr(
getattr(agent, "context_compressor", None),
"note_native_compaction_checkpoint",
None,
)
if callable(note_checkpoint):
try:
from agent.codex_responses_adapter import (
has_replayable_native_compaction_checkpoint,
)
if has_replayable_native_compaction_checkpoint(
agent, conversation_history
):
note_checkpoint()
except Exception:
logger.debug(
"restored native checkpoint hydration skipped", exc_info=True
)
# Hydrate per-session nudge counters from persisted history.
prior_user_turns = sum(1 for m in conversation_history if m.get("role") == "user")
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
+5
View File
@@ -156,6 +156,11 @@ def _idle_compaction(
if _idle_gap < _idle_after:
return
_compressor = agent.context_compressor
# A live or restored native checkpoint must reach its issuer once so real usage,
# rather than an opaque ciphertext estimate, decides whether local compression is
# still needed. Threshold and post-tool preflight honor the same latch.
if bool(getattr(_compressor, "awaiting_real_usage_after_compression", False)):
return
# Route-aware pressure: on compacted native-Codex sessions the durable figure
# overstates the wire, so reuse the preflight estimator.
_idle_tokens = _tc._preflight_request_tokens(
+6
View File
@@ -39,6 +39,12 @@ def prepare_iteration(agent: Any,*, messages: Any, api_call_count: Any) -> Itera
_INTERRUPT_SCAFFOLD_MARKER, _maybe_inject_run_budget_wrapup
)
# nous.anthropic_wire=auto: a wire switch decided from the previous response lands here,
# before this iteration's request is built and with nothing in flight.
if getattr(agent, "_nous_wire_pending", None):
from agent.nous_wire import apply_pending_wire_switch
apply_pending_wire_switch(agent)
# Fire step_callback for gateway hooks (agent:step event).
if agent.step_callback is not None:
try:
+3
View File
@@ -297,6 +297,9 @@ def compress_after_tool_results(
if (
agent.compression_enabled
and compression_attempts < max_compression_attempts
and not bool(
getattr(_compressor, "awaiting_real_usage_after_compression", False)
)
and _compressor.should_compress(_real_tokens)
):
compression_attempts += 1
+45 -21
View File
@@ -892,7 +892,8 @@ def log_api_error_attempt(
if agent._is_openrouter_url() and "support tool use" in error_msg:
_blines(agent, f" 💡 No OpenRouter providers for {_model} support tool calling with your current settings.")
if agent.providers_allowed:
from agent.chat_completion_helpers import _provider_preferences_for_agent
if _provider_preferences_for_agent(agent).get("only"):
_blines(
agent,
" Your provider_routing.only restriction is filtering out tool-capable providers.",
@@ -973,30 +974,44 @@ def compute_error_backoff(
agent: Any, api_error: Exception, *, retry_count: int, max_retries: int, is_rate_limited: bool,
is_zai_coding_overload: bool, base_url: Any, model: Any,
) -> float:
"""Pick the wait before the next API retry and announce it. Retry-After wins for rate
limits (capped at 600s: Anthropic Tier 1 buckets reset in ~171s, so a 120s cap re-tripped
the limit); otherwise jittered backoff, replaced by the adaptive policy for 429s / Z.AI
overloads. Normal retries are buffered; long Z.AI Coding waits surface immediately."""
"""Pick the wait before the next API retry and announce it. Retry-After wins for
rate limits and any other retryable error (capped at 600s: Anthropic Tier 1 buckets
reset in ~171s, so a 120s cap re-tripped the limit); otherwise jittered backoff,
replaced by the adaptive policy for 429s / Z.AI overloads. Normal retries are
buffered; long Z.AI Coding waits surface immediately."""
# Imported lazily so tests that patch ``agent.retry_utils.jittered_backoff`` /
# ``adaptive_rate_limit_backoff`` (incl. the run_agent conftest fast-backoff fixture) intercept.
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff, parse_retry_after_seconds
_retry_after = None
_resp_headers = getattr(getattr(api_error, "response", None), "headers", None) if is_rate_limited else None
if _resp_headers and hasattr(_resp_headers, "get"):
_ra_raw = _resp_headers.get("retry-after") or _resp_headers.get("Retry-After")
if _ra_raw:
try:
# Cap at 10 minutes. Anthropic Tier 1 input-token buckets reset in ~171s, so a 120s cap
# caused us to retry before the actual reset window and re-trip the limit. 600s covers all
# realistic provider reset windows while still rejecting pathological values. (#26293)
_retry_after = min(float(_ra_raw), 600)
except (TypeError, ValueError):
pass
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
# Respect Retry-After on every retryable provider error, not just 429s. Retryable
# 5xx responses (e.g. Cloudflare 520/524) also carry the header or a structured
# ``retry_after`` problem-detail body field; ignoring either turns an origin
# outage into a retry storm.
_retry_after = parse_retry_after_seconds(
getattr(getattr(api_error, "response", None), "headers", None)
)
if _retry_after is None:
_error_body = getattr(api_error, "body", None)
if isinstance(_error_body, dict):
# Some providers nest it as error.retry_after (the same unwrap
# extract_api_error_context uses), others put it at the top level.
_nested = _error_body.get("error")
_payload = _nested if isinstance(_nested, dict) else _error_body
_retry_after = parse_retry_after_seconds(_payload.get("retry_after"))
if _retry_after is not None:
# Cap at 10 minutes. Anthropic Tier 1 input-token buckets reset in ~171s, so a 120s cap
# caused us to retry before the actual reset window and re-trip the limit. 600s covers all
# realistic provider reset windows while still rejecting pathological values. (#26293)
_retry_after = min(_retry_after, 600)
if _retry_after <= 0:
# A zero/expired cooldown (retry-after: 0, or an HTTP-date in the
# past, which the parser clamps to 0.0) carries no usable wait —
# treat it as absent so we never hot-loop the provider.
_retry_after = None
wait_time = _retry_after if _retry_after is not None else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
_backoff_policy = None
_adaptive = is_rate_limited or is_zai_coding_overload
if _adaptive and not _retry_after:
if _adaptive and _retry_after is None:
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
retry_count, base_url=str(base_url), model=model, error=api_error, default_wait=wait_time,
)
@@ -1009,7 +1024,16 @@ def compute_error_backoff(
else:
agent._buffer_status(_rate_limit_status)
else:
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
_retry_status = (
f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})..."
)
if _retry_after is not None and _retry_after > 60:
# A 5xx Retry-After can now reach the 600s cap; buffering that wait
# would leave the user silent for minutes, so surface long provider
# cooldowns immediately (mirrors the zai_coding_overload_long path).
agent._emit_status(_retry_status)
else:
agent._buffer_status(_retry_status)
logger.warning(
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
wait_time, retry_count, max_retries, agent._client_log_context(),
+5
View File
@@ -181,6 +181,11 @@ def record_response_usage(
prompt_tokens, completion_tokens, total_tokens,
api_duration, _cache_pct,
)
# nous.anthropic_wire=auto: the session's wire is decided once, from this first response.
if agent.session_api_calls == 1 and (agent.provider or "") == "nous":
with suppress(Exception):
from agent.nous_wire import maybe_switch_wire_after_first_response
maybe_switch_wire_after_first_response(agent, response, agent.session_api_calls)
# MoA: agent.model/provider are the virtual preset/"moa" with no pricing entry, silently
# dropping aggregator spend. Price at the REAL model/provider from the aggregator slot.
+3
View File
@@ -191,6 +191,9 @@ _SNAPSHOTS: tuple[tuple[str, Optional[str], str, dict], ...] = (
("deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"): ("0.14", "0.28", "0.0028"),
"deepseek-v4-pro": ("0.435", "0.87", "0.003625"),
}),
("google", "https://ai.google.dev/gemini-api/docs/pricing", "google-pricing-2026-09-02", {
("gemini-3.8-flash", "gemini-3.7-flash"): ("0.75", "3.75", "0.075"),
}),
("google", "https://ai.google.dev/gemini-api/docs/pricing", "google-pricing-2026-07-28", {
"gemini-3.6-flash": ("1.50", "7.50", "0.15"), "gemini-3.5-flash-lite": ("0.30", "2.50", "0.03"),
}),
+48 -1
View File
@@ -182,6 +182,30 @@ def _run_start_phase(
return ReadinessResult(url, ready, status, time.monotonic() - started, error, _tail(output))
def _compose_live_state_reason(root: Path) -> str | None:
"""Why ``docker compose build``/``up`` must not run at *root*, or ``None`` to proceed.
Read-only ``docker compose ps`` probe. Only a missing docker binary proceeds -- the
build phase would fail the same way, so there is nothing to protect. A hung daemon
or a non-zero probe refuses: containers may be live and unobservable, which is
exactly the #103567 loss window.
"""
try:
result = subprocess.run(
["docker", "compose", "ps", "--status", "running", "--format", "{{.Name}}"],
cwd=root, capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return None
except subprocess.TimeoutExpired:
return "docker compose ps timed out after 15s; live containers cannot be ruled out"
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip().splitlines()
return f"docker compose ps failed (exit {result.returncode}): {detail[-1] if detail else 'no output'}"
names = [line for line in result.stdout.splitlines() if line.strip()]
return f"this compose project already has running container(s): {', '.join(names)}" if names else None
def run_verify(
root: Path, recipe: Recipe, phases: tuple[str, ...] | list[str] | None = None,
phase_timeout: float = DEFAULT_PHASE_TIMEOUT, ready_timeout: float = DEFAULT_READY_TIMEOUT,
@@ -189,11 +213,34 @@ def run_verify(
on_output: Callable[[str], None] | None = None,
) -> VerifyResult:
"""Run the selected command phases sequentially, then (unless ``skip_start`` or a
phase failed) boot ``recipe.start``, poll readiness, and tear the process group down."""
phase failed) boot ``recipe.start``, poll readiness, and tear the process group down.
A ``compose`` recipe refuses outright when the project already has running
containers: ``docker compose build`` + ``up`` replaces them on an image-hash
change, destroying any container-local state they carry -- this has caused a
real state-loss incident (#103567). The check is best-effort and read-only
(``docker compose ps``); when it cannot run at all, verify proceeds rather than
blocking on an unrelated environment gap, matching every other recipe kind's
behavior when its own tooling is unavailable."""
root = Path(root)
selected = tuple(phases) if phases else PHASE_ORDER + ("start",)
result = VerifyResult(recipe_name=recipe.name)
mutating = ("build" in selected) or ("start" in selected and not skip_start)
if recipe.kind == "compose" and mutating:
reason = _compose_live_state_reason(root)
if reason:
result.phases.append(PhaseResult(
phase="build", command=recipe.build[0] if recipe.build else "docker compose build",
exit_code=1, duration=0.0, output_tail=(
f"Refusing to run: {reason}. `docker compose build` + `up` would replace "
"live containers on an image-hash change, destroying any container-local "
"state they carry. If you intend to rebuild this live deployment, run "
"`docker compose build`/`up` yourself."
),
))
return result
for phase in PHASE_ORDER:
if phase not in selected:
continue
@@ -126,10 +126,10 @@ describe('main.ts wiring for #90812', () => {
it('routes the profile-scoped dial IPC through the single-owner claim', () => {
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection', ")
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 900)
const body = mainSource.slice(handlerStart, handlerStart + 1200)
expect(body).toContain('backendDialClaims.run(')
expect(body).toContain('ensureBackend(profile)')
expect(body).toContain('ensureBackend(profile, { spawnPriority })')
})
it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => {
@@ -137,8 +137,9 @@ describe('main.ts wiring for #90812', () => {
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 1_200)
expect(body).toContain('backendDialClaims.run(backendScopeKey(id, profile)')
expect(body).toContain('ensureRegistryBackend(id, profile)')
expect(body).toContain('const scopeKey = backendScopeKey(id, profile)')
expect(body).toContain('backendDialClaims.run(scopeKey, ')
expect(body).toContain("ensureRegistryBackend(id, profile, '', { spawnPriority })")
})
// The four IPC/probe surfaces below call ensureRegistryBackend()/ensureBackend()
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { normalizeRegistry, REGISTRY_VERSION } from './connection-registry'
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
import { backendScopeKey } from './connection-registry'
import { resolveDesktopRemoteRoute, v1SshTerminalPoolKey } from './desktop-remote-route'
const tokenA = { encoding: 'plain', value: 'token-a' }
const tokenB = { encoding: 'plain', value: 'token-b' }
@@ -153,6 +154,46 @@ test('global SSH treats an omitted port as 22 and checks the primary route', ()
assert.equal(route?.connectionId, 'ssh-primary')
})
test('v1 settings SSH pool key ignores registry identity tags', () => {
const route = resolveDesktopRemoteRoute({
config: { mode: 'ssh', remote: { mode: 'ssh', host: 'box.test', user: 'hermes' } },
profile: 'worker',
registry: registry('ssh-primary', [
{ id: 'ssh-primary', kind: 'ssh', label: 'SSH primary', host: 'box.test', user: 'hermes', port: 22 }
])
})
assert.ok(route)
assert.equal(route.kind, 'ssh')
assert.equal(route.connectionId, 'ssh-primary')
assert.equal(v1SshTerminalPoolKey(route, 'worker'), '')
assert.notEqual(v1SshTerminalPoolKey(route, 'worker'), backendScopeKey(route.connectionId, 'worker'))
})
test('v1 profile SSH pool key is the profile, not conn:id::profile', () => {
const ssh = {
mode: 'ssh',
host: 'box.test',
user: 'hermes',
port: 2222,
keyPath: '/keys/a',
remoteHermesPath: '/srv/hermes',
remoteProfile: 'worker'
}
const route = resolveDesktopRemoteRoute({
config: { mode: 'local', profiles: { worker: ssh } },
profile: 'worker',
registry: registry('local', [{ id: 'worker-ssh', kind: 'ssh', label: 'Worker SSH', ...ssh }])
})
assert.ok(route)
assert.equal(route.kind, 'ssh')
assert.equal(route.connectionId, 'worker-ssh')
assert.equal(v1SshTerminalPoolKey(route, 'worker'), 'worker')
assert.notEqual(v1SshTerminalPoolKey(route, 'worker'), backendScopeKey(route.connectionId, 'worker'))
})
test('profile route omits identity when two registry entries match exactly', () => {
const block = { mode: 'remote', url: 'https://worker.test', authMode: 'token', token: tokenA }
@@ -51,6 +51,22 @@ function withConnectionId<T extends object>(route: T, connectionId?: string): T
return connectionId ? { ...route, connectionId } : route
}
/**
* Pool key used by v1 settings/profile SSH (`sshConnections`).
*
* Bootstrap stores the tunnel under sshScopeKey(profile or null).
* resolveDesktopRemoteRoute may also stamp a registry connectionId when
* the host matches a v2 row. That id is identity, not the pool key.
* Looking up backendScopeKey(connectionId, profile) misses the live tunnel.
*/
export function v1SshTerminalPoolKey(route: { source: string }, profile?: null | string): string {
if (route.source === 'profile') {
return connectionScopeKey(profile) || ''
}
return ''
}
/**
* Select one remote route with the existing precedence and freeze any exact
* registry identity before I/O. A null result means the profile resolves
+154 -24
View File
@@ -158,7 +158,7 @@ import { describeCrashReason, installCrashForensics } from './crash-forensics'
import { adoptServedDashboardToken } from './dashboard-token'
import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation'
import { formatDesktopLogLine } from './desktop-log-line'
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
import { resolveDesktopRemoteRoute, v1SshTerminalPoolKey } from './desktop-remote-route'
import {
buildPosixCleanupScript,
buildWindowsCleanupScript,
@@ -287,7 +287,9 @@ import {
import { selectPoolEvictions } from './pool-eviction'
import { clampPoolLimits, parsePoolLimits, POOL_LIMITS_DEFAULTS } from './pool-limits'
import {
isBackgroundSlotWaitTimeout,
LocalBackendSpawnCoordinator,
type LocalBackendSpawnPriority,
type LocalBackendSpawnRequest,
releaseLocalBackendSlotAfterExit
} from './pool-spawn-coordinator'
@@ -411,7 +413,11 @@ import { isHermesOwnedVenvDaemon } from './venv-holder-select'
import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace'
import { createWakeIndicatorWindowController } from './wake-indicator-window'
import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below'
import { registrySshScopeForWindowRoute, WindowConnectionRouteRegistry } from './window-connection-route'
import {
registrySshPoolScopeByConnectionId,
registrySshScopeForWindowRoute,
WindowConnectionRouteRegistry
} from './window-connection-route'
import { createWindowOpenHandler } from './window-open-policy'
import { installWindowRendererLifecycle } from './window-renderer-lifecycle'
import { createWindowRevealController } from './window-reveal'
@@ -1488,6 +1494,70 @@ const localBackendSpawnCoordinator = new LocalBackendSpawnCoordinator(poolLimits
// the queued ticket fails before the renderer does and the user sees why.
const POOL_SLOT_WAIT_MS = 30_000
function spawnPriorityFrom(value: unknown): LocalBackendSpawnPriority {
return value === 'foreground' ? 'foreground' : 'background'
}
// Foreground intent for a dial whose pool entry does not exist yet: a user
// click that joins an in-flight backendDialClaims claim never re-enters
// ensureBackend(), and the claim owner may still be awaiting poolStopper /
// registry resolution before backendPool.set(). The local spawn takes the mark
// right before its slot request; the IPC handler that set it clears it once
// the claim settles, so a dial that never reaches a slot request (primary
// route, remote scope, a guard rejection) cannot leave it for a later
// hydration spawn of the same key to pick up.
const pendingForegroundSpawns = new Set<string>()
function takeForegroundSpawn(...poolKeys: string[]): boolean {
let marked = false
for (const poolKey of poolKeys) {
marked = pendingForegroundSpawns.delete(poolKey) || marked
}
return marked
}
// Upgrade a pooled entry (running, spawning, or queued for a slot) to
// foreground so a queued slot wait can take the reserved foreground slot.
function promotePoolEntry(entry: any): void {
entry.spawnPriority = 'foreground'
entry.localBackendSpawnRequest?.promote?.('foreground')
}
// Land a spawn failure in desktop.log. A background slot-wait timeout is
// routine under a saturated pool (the next hydration pass retries), so it is
// logged as such instead of as a backend-start failure.
function logPoolSpawnFailure(label: string, error: unknown): void {
if (isBackgroundSlotWaitTimeout(error)) {
rememberLog(`Profile backend ${label} slot wait timed out (background); will retry on the next hydration`)
} else {
rememberLog(
`Hermes backend for profile ${label} failed to start: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Apply foreground intent to the dial claim for `scopeKey`: an entry already
// in the pool is promoted directly, otherwise the intent is marked for the
// spawn the claim owner is about to start. Returns the cleanup that clears a
// mark the dial never consumed.
function applySpawnPriority(scopeKey: string, spawnPriority: LocalBackendSpawnPriority): () => void {
if (spawnPriority !== 'foreground') {
return () => undefined
}
const existing = backendPool.get(scopeKey)
if (existing) {
promotePoolEntry(existing)
} else {
pendingForegroundSpawns.add(scopeKey)
}
return () => void pendingForegroundSpawns.delete(scopeKey)
}
function poolMaxBackends() {
return poolLimits.maxBackends
}
@@ -10315,7 +10385,18 @@ function activeSshTerminalTarget(webContentsId?: number) {
const state = sshConnections.get(scope)
return state && state.ssh ? { ssh: state.ssh, scope } : 'pending'
if (state && state.ssh) {
return { ssh: state.ssh, scope }
}
// The pool's single writer publishes under the per-profile bootstrap key
// while stamping the entry with its registry connection id (#97345), so a
// composite-key miss must still resolve the live tunnel by that identity
// instead of reporting 'pending' forever.
const pooledScope = registrySshPoolScopeByConnectionId(sshConnections, windowRoute.connectionId)
const pooledState = pooledScope === null ? null : sshConnections.get(pooledScope)
return pooledState && pooledState.ssh ? { ssh: pooledState.ssh, scope: pooledScope } : 'pending'
}
const profile = windowRoute?.profile ?? primaryProfileKey()
@@ -10335,9 +10416,7 @@ function activeSshTerminalTarget(webContentsId?: number) {
return null
}
const scope = route.connectionId
? backendScopeKey(route.connectionId, profile)
: sshScopeKey(route.source === 'profile' ? profile : null)
const scope = v1SshTerminalPoolKey(route, profile)
const state = sshConnections.get(scope)
@@ -11369,8 +11448,9 @@ function profileRouteOptions(profile, request?) {
// Resolve a backend connection for the given profile, per the routing table in
// resolveProfileBackendRoute(). An empty / unknown profile resolves to the
// primary, so legacy callers are unchanged.
async function ensureBackend(profile) {
async function ensureBackend(profile, opts: { spawnPriority?: LocalBackendSpawnPriority } = {}) {
const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
const spawnPriority = spawnPriorityFrom(opts.spawnPriority)
profileDeletionGate.assertCanStart(key)
@@ -11402,6 +11482,11 @@ async function ensureBackend(profile) {
if (existing) {
existing.lastActiveAt = Date.now()
if (spawnPriority === 'foreground') {
promotePoolEntry(existing)
}
const connection = await existing.connectionPromise
setWslBridgeProfileState(key, connection.mode !== 'remote')
@@ -11419,16 +11504,15 @@ async function ensureBackend(profile) {
remoteBaseUrl: null,
releaseLocalBackendSlot: null,
localBackendSlotKey: null,
localBackendSpawnRequest: null
localBackendSpawnRequest: null,
spawnPriority
}
entry.connectionPromise = spawnPoolBackend(key, entry).catch(async error => {
// Land the failure in desktop.log: without this a spawn that dies before
// its child exists (guard rejection, runtime resolution) leaves no trace
// beyond renderer-side rejections users never see in a bundle.
rememberLog(
`Hermes backend for profile "${key}" failed to start: ${error instanceof Error ? error.message : String(error)}`
)
logPoolSpawnFailure(`"${key}"`, error)
await teardownFailedLocalBackend(key, entry)
throw error
@@ -11449,7 +11533,13 @@ async function ensureBackend(profile) {
// a genuinely-local child when the v1 mode says remote; non-local connections
// pool under the composite key from backendScopeKey() and reuse the same pool
// entry lifecycle (LRU, idle reaper, touch) as per-profile local backends.
async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrelation = '') {
async function ensureRegistryBackend(
connectionId,
profile,
managedUpdateCorrelation = '',
opts: { spawnPriority?: LocalBackendSpawnPriority } = {}
) {
const spawnPriority = spawnPriorityFrom(opts.spawnPriority)
const registry = readDesktopConnectionsRegistry()
const id = String(connectionId || '').trim() || registry.primary
const source = registry.connections.find(c => c.id === id)
@@ -11506,7 +11596,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
const primary = await reuseMatchingPrimarySshBackend({
connectionId: id,
effectiveFingerprint: resolveRegistryEffectiveFingerprint,
ensurePrimary: () => ensureBackend(profile),
ensurePrimary: () => ensureBackend(profile, { spawnPriority }),
profile,
registry,
source
@@ -11557,7 +11647,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
})
if (localRoute.delegate) {
return ensureBackend(profile)
return ensureBackend(profile, { spawnPriority })
}
const stoppingLocal = poolStopper.inFlight(localRoute.poolKey)
@@ -11571,6 +11661,10 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
if (existingLocal) {
existingLocal.lastActiveAt = Date.now()
if (spawnPriority === 'foreground') {
promotePoolEntry(existingLocal)
}
return existingLocal.connectionPromise
}
@@ -11585,7 +11679,8 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
remoteBaseUrl: null,
releaseLocalBackendSlot: null,
localBackendSlotKey: null,
localBackendSpawnRequest: null
localBackendSpawnRequest: null,
spawnPriority
}
localEntry.connectionPromise = spawnPoolBackend(profileKey, localEntry, {
@@ -11594,9 +11689,7 @@ async function ensureRegistryBackend(connectionId, profile, managedUpdateCorrela
}).catch(async error => {
// Same trace rule as the v1 pool path: a forced-local child whose spawn
// rejects before the child exists must still land in desktop.log.
rememberLog(
`Hermes backend for profile "${profileKey}" (forced-local) failed to start: ${error instanceof Error ? error.message : String(error)}`
)
logPoolSpawnFailure(`"${profileKey}" (forced-local)`, error)
await teardownFailedLocalBackend(localRoute.poolKey, localEntry)
throw error
@@ -12434,11 +12527,23 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po
// pool-idle window (10 min) would hold the pool key hostage and every
// later click on the profile would join that stale wait. Failing here
// surfaces the "all N slots busy" reason instead of a generic boot timeout.
const spawnRequest = localBackendSpawnCoordinator.request(poolKey, { timeoutMs: POOL_SLOT_WAIT_MS })
// The caller stamped entry.spawnPriority from its own request; a foreground
// dial that joined the claim before this entry existed left a mark instead.
if (takeForegroundSpawn(poolKey, profile)) {
entry.spawnPriority = 'foreground'
}
const spawnPriority: LocalBackendSpawnPriority = spawnPriorityFrom(entry.spawnPriority)
const spawnRequest = localBackendSpawnCoordinator.request(poolKey, {
timeoutMs: POOL_SLOT_WAIT_MS,
priority: spawnPriority
})
entry.localBackendSlotKey = poolKey
entry.localBackendSpawnRequest = spawnRequest
if (localBackendSpawnCoordinator.activeCount >= poolMaxBackends()) {
if (spawnRequest.queued) {
rememberLog(
`Profile backend "${profile}" waiting for a free local slot (${localBackendSpawnCoordinator.activeCount}/${poolMaxBackends()} busy, ${localBackendSpawnCoordinator.queuedCount} queued)`
)
@@ -14698,13 +14803,27 @@ function createWindow() {
})
}
ipcMain.handle('hermes:connection', async (_event, profile) => {
ipcMain.handle('hermes:connection', async (_event, profile, extra) => {
// Coalesce concurrent renderer dials for one profile scope (#90812): the
// renderer-side reconnect lock is per-window, so two windows waking at once
// both land here. The claim key mirrors ensureBackend()'s own profile
// normalization so every spelling of the primary coalesces onto one dial.
const profileKey = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey()
const connection = await backendDialClaims.run(backendScopeKey(null, profileKey), () => ensureBackend(profile))
// A user click may join an in-flight hydration claim; the foreground intent
// is applied to that claim so its slot wait can take the reserved slot.
const spawnPriority = spawnPriorityFrom(extra?.priority)
const scopeKey = backendScopeKey(null, profileKey)
const clearSpawnPriority = applySpawnPriority(scopeKey, spawnPriority)
let connection
try {
connection = await backendDialClaims.run(scopeKey, () => ensureBackend(profile, { spawnPriority }))
} finally {
clearSpawnPriority()
}
const connectionId = resolvedConnectionId(readDesktopConnectionsRegistry(), connection)
return connectionId ? { ...connection, connectionId } : connection
@@ -14715,13 +14834,24 @@ ipcMain.handle('hermes:connection', async (_event, profile) => {
// forces a genuinely-local child when the v1 global mode is remote (the
// registry 'local' entry always means this machine).
ipcMain.handle('hermes:connection:for', async (_event, payload) => {
const { connectionId, profile } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
const { connectionId, profile, priority } = payload && typeof payload === 'object' ? (payload as any) : ({} as any)
const registry = readDesktopConnectionsRegistry()
const id = String(connectionId || '').trim() || registry.primary
const spawnPriority = spawnPriorityFrom(priority)
// Same single-owner claim as 'hermes:connection', keyed by the composite
// (connectionId, profile) scope (#90812): concurrent registry dials for one
// scope share the first spawn instead of bootstrapping duplicate remotes.
const connection = await backendDialClaims.run(backendScopeKey(id, profile), () => ensureRegistryBackend(id, profile))
const scopeKey = backendScopeKey(id, profile)
const clearSpawnPriority = applySpawnPriority(scopeKey, spawnPriority)
let connection
try {
connection = await backendDialClaims.run(scopeKey, () => ensureRegistryBackend(id, profile, '', { spawnPriority }))
} finally {
clearSpawnPriority()
}
return { ...connection, connectionId: id, registryScoped: true }
})
@@ -6,7 +6,11 @@ import { fileURLToPath } from 'node:url'
import { test } from 'vitest'
import { LocalBackendSpawnCoordinator, releaseLocalBackendSlotAfterExit } from './pool-spawn-coordinator'
import {
LocalBackendSlotWaitTimeoutError,
LocalBackendSpawnCoordinator,
releaseLocalBackendSlotAfterExit
} from './pool-spawn-coordinator'
const deferred = () => {
let resolve!: () => void
@@ -306,6 +310,197 @@ test('setLimit rejects a non-positive or fractional cap', () => {
assert.equal(coordinator.limit, 2)
})
test('cap 3: two background leases leave a reserved slot for foreground', async () => {
const coordinator = new LocalBackendSpawnCoordinator(3)
const bg1 = await coordinator.request('bg-1', { priority: 'background' }).acquired
const bg2 = await coordinator.request('bg-2', { priority: 'background' }).acquired
assert.equal(coordinator.activeCount, 2)
assert.equal(coordinator.queuedCount, 0)
let fgGranted = false
const fgPromise = coordinator.request('fg', { priority: 'foreground' }).acquired.then(release => {
fgGranted = true
return release
})
await flush()
assert.equal(fgGranted, true)
assert.equal(coordinator.activeCount, 3)
const releaseFg = await fgPromise
bg1()
bg2()
releaseFg()
assert.equal(coordinator.activeCount, 0)
})
test('untagged acquire still fills the cap (foreground default)', async () => {
const coordinator = new LocalBackendSpawnCoordinator(3)
const releases = await Promise.all(['a', 'b', 'c'].map(key => coordinator.acquire(key)))
assert.equal(coordinator.activeCount, 3)
assert.equal(coordinator.queuedCount, 0)
for (const release of releases) {
release()
}
assert.equal(coordinator.activeCount, 0)
})
test('foreground is granted the reserved slot ahead of a background hydration queue', async () => {
const coordinator = new LocalBackendSpawnCoordinator(3)
const bgRunning = await Promise.all(
['bg-run-1', 'bg-run-2'].map(key => coordinator.request(key, { priority: 'background' }).acquired)
)
const queued = Array.from({ length: 20 }, (_, index) =>
coordinator.request(`bg-wait-${index}`, { priority: 'background', timeoutMs: 5_000 })
)
await flush()
assert.equal(coordinator.activeCount, 2)
assert.equal(coordinator.queuedCount, 20)
const started = Date.now()
const releaseFg = await coordinator.request('user-click', { priority: 'foreground', timeoutMs: 100 }).acquired
assert.ok(Date.now() - started < 80, 'foreground must not wait behind the background queue')
assert.equal(coordinator.activeCount, 3)
for (const request of queued) {
request.cancel()
}
releaseFg()
for (const release of bgRunning) {
release()
}
await Promise.all(
queued.map(request =>
request.acquired.then(
() => undefined,
() => undefined
)
)
)
assert.equal(coordinator.activeCount, 0)
assert.equal(coordinator.queuedCount, 0)
})
test('drain prefers a foreground waiter over an earlier background waiter', async () => {
const coordinator = new LocalBackendSpawnCoordinator(1)
const releaseHolder = await coordinator.acquire('holder')
const background = coordinator.request('background', { priority: 'background' })
const foreground = coordinator.request('foreground', { priority: 'foreground' })
await flush()
assert.equal(coordinator.queuedCount, 2)
let backgroundEntered = false
let foregroundEntered = false
const backgroundGrant = background.acquired.then(release => {
backgroundEntered = true
return release
})
const foregroundGrant = foreground.acquired.then(release => {
foregroundEntered = true
return release
})
releaseHolder()
await flush()
assert.equal(foregroundEntered, true)
assert.equal(backgroundEntered, false)
assert.equal(coordinator.activeCount, 1)
const releaseForeground = await foregroundGrant
releaseForeground()
const releaseBackground = await backgroundGrant
assert.equal(backgroundEntered, true)
releaseBackground()
assert.equal(coordinator.activeCount, 0)
})
test('background slot-wait timeout is distinguishable; foreground keeps a user-facing message', async () => {
const coordinator = new LocalBackendSpawnCoordinator(1)
const releaseFirst = await coordinator.acquire('first')
const background = coordinator.request('bg', { priority: 'background', timeoutMs: 10 })
await assert.rejects(background.acquired, error => {
assert.ok(error instanceof LocalBackendSlotWaitTimeoutError)
assert.equal(error.name, 'LocalBackendSlotWaitTimeoutError')
assert.equal(error.priority, 'background')
assert.equal(error.silent, true)
assert.match(error.message, /timed out while waiting for a free slot/)
assert.match(error.message, /\(background\)/)
return true
})
const foreground = coordinator.request('fg', { priority: 'foreground', timeoutMs: 10 })
await assert.rejects(foreground.acquired, error => {
assert.ok(error instanceof Error)
assert.match(error.message, /timed out while waiting for a free slot/)
assert.doesNotMatch(error.message, /\(background\)/)
assert.notEqual(error.name, 'LocalBackendSlotWaitTimeoutError')
return true
})
releaseFirst()
assert.equal(coordinator.activeCount, 0)
})
test('request() reports whether the caller actually waited behind the queue', async () => {
const coordinator = new LocalBackendSpawnCoordinator(3)
const bg1 = coordinator.request('bg-1', { priority: 'background' })
const bg2 = coordinator.request('bg-2', { priority: 'background' })
const bgWait = coordinator.request('bg-3', { priority: 'background' })
assert.equal(bg1.queued, false)
assert.equal(bg2.queued, false)
assert.equal(bgWait.queued, true)
// The reserved slot is free: a foreground request is granted immediately
// even though a background waiter is queued.
const fg = coordinator.request('fg', { priority: 'foreground' })
assert.equal(fg.queued, false)
assert.equal(coordinator.activeCount, 3)
bgWait.cancel()
await bgWait.acquired.catch(() => undefined)
;(await fg.acquired)()
;(await bg1.acquired)()
;(await bg2.acquired)()
assert.equal(coordinator.activeCount, 0)
})
test('promoting a queued background waiter lets it take the reserved foreground slot', async () => {
const coordinator = new LocalBackendSpawnCoordinator(3)
const bg1 = await coordinator.request('bg-1', { priority: 'background' }).acquired
const bg2 = await coordinator.request('bg-2', { priority: 'background' }).acquired
const queued = coordinator.request('same-bot', { priority: 'background' })
await flush()
assert.equal(coordinator.activeCount, 2)
assert.equal(coordinator.queuedCount, 1)
assert.equal(queued.promote('foreground'), true)
const releasePromoted = await queued.acquired
assert.equal(coordinator.activeCount, 3)
assert.equal(coordinator.queuedCount, 0)
releasePromoted()
bg1()
bg2()
assert.equal(coordinator.activeCount, 0)
})
// ── main.ts wiring ──────────────────────────────────────────────────────────
// The coordinator is only as good as the timeout main.ts hands it. A queued
// ticket that outlives the renderer's backend-boot budget holds the pool key
@@ -330,7 +525,10 @@ test('setLimit rejects a non-positive or fractional cap', () => {
assert.ok(Number.isFinite(slotWait) && slotWait > 0, 'POOL_SLOT_WAIT_MS must be a literal in main.ts')
assert.ok(Number.isFinite(bootBudget), 'BACKEND_BOOT_WAIT_TIMEOUT_MS must be a literal')
assert.ok(slotWait < bootBudget, `slot wait ${slotWait}ms must be below the boot budget ${bootBudget}ms`)
assert.match(mainSource, /localBackendSpawnCoordinator\.request\(poolKey, \{ timeoutMs: POOL_SLOT_WAIT_MS \}\)/)
assert.match(
mainSource,
/localBackendSpawnCoordinator\.request\(poolKey, \{\s*timeoutMs: POOL_SLOT_WAIT_MS,\s*priority: spawnPriority\s*\}\)/
)
assert.doesNotMatch(mainSource, /request\(poolKey, \{ timeoutMs: POOL_IDLE_MS \}\)/)
})
+138 -19
View File
@@ -1,17 +1,47 @@
export type ReleaseLocalBackendSlot = () => void
export type LocalBackendSpawnPriority = 'foreground' | 'background'
export type LocalBackendSpawnRequest = {
acquired: Promise<ReleaseLocalBackendSlot>
cancel: () => boolean
promote: (priority: LocalBackendSpawnPriority) => boolean
/** False when the slot was granted without waiting behind the queue. */
queued: boolean
}
type Waiter = {
key: string
priority: LocalBackendSpawnPriority
resolve: (release: ReleaseLocalBackendSlot) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout> | null
}
const SLOT_WAIT_TIMEOUT_MESSAGE = (key: string) =>
`Local backend start for "${key}" timed out while waiting for a free slot.`
/**
* Slot-wait timeout. Background hydrations set `silent` so call sites can fail
* quiet instead of toasting a user-visible backend-start failure.
*/
export class LocalBackendSlotWaitTimeoutError extends Error {
readonly priority: LocalBackendSpawnPriority
readonly silent: boolean
constructor(key: string, priority: LocalBackendSpawnPriority) {
const suffix = priority === 'background' ? ' (background)' : ''
super(`${SLOT_WAIT_TIMEOUT_MESSAGE(key)}${suffix}`)
this.name = 'LocalBackendSlotWaitTimeoutError'
this.priority = priority
this.silent = priority === 'background'
}
}
export function isBackgroundSlotWaitTimeout(error: unknown): boolean {
return error instanceof LocalBackendSlotWaitTimeoutError && error.silent
}
export async function releaseLocalBackendSlotAfterExit(
release: ReleaseLocalBackendSlot,
waitForExit: () => Promise<void>
@@ -25,10 +55,15 @@ export async function releaseLocalBackendSlotAfterExit(
*
* A lease is acquired immediately before local start work and is held until
* the child exits or the start fails. Remote descriptors never call request().
*
* When the cap is at least 2, one slot is reserved for foreground (user-open)
* requests so background roster hydration cannot occupy the whole pool.
* Untagged acquire() is foreground, so existing cap tests still fill `limit`.
*/
export class LocalBackendSpawnCoordinator {
#limit: number
#active = 0
#activeForeground = 0
#activeBackground = 0
#queue: Waiter[] = []
constructor(limit: number) {
@@ -40,7 +75,7 @@ export class LocalBackendSpawnCoordinator {
}
get activeCount(): number {
return this.#active
return this.#activeForeground + this.#activeBackground
}
get limit(): number {
@@ -66,39 +101,47 @@ export class LocalBackendSpawnCoordinator {
return this.#queue.length
}
request(key: string, options: { timeoutMs?: number } = {}): LocalBackendSpawnRequest {
request(
key: string,
options: { timeoutMs?: number; priority?: LocalBackendSpawnPriority } = {}
): LocalBackendSpawnRequest {
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1)) {
throw new RangeError('Local backend spawn timeout must be a positive number.')
}
if (this.#active < this.#limit) {
const priority: LocalBackendSpawnPriority = options.priority === 'background' ? 'background' : 'foreground'
if (this.#queue.length === 0 && this.#canGrant(priority)) {
return {
acquired: Promise.resolve(this.#grant()),
cancel: () => false
acquired: Promise.resolve(this.#grant(priority)),
cancel: () => false,
promote: () => false,
queued: false
}
}
let waiter!: Waiter
const acquired = new Promise<ReleaseLocalBackendSlot>((resolve, reject) => {
waiter = { key, resolve, reject, timer: null }
waiter = { key, priority, resolve, reject, timer: null }
this.#queue.push(waiter)
if (options.timeoutMs !== undefined) {
waiter.timer = setTimeout(() => {
this.#rejectWaiter(
waiter,
new Error(`Local backend start for "${key}" timed out while waiting for a free slot.`)
)
this.#rejectWaiter(waiter, this.#timeoutError(waiter))
}, options.timeoutMs)
waiter.timer.unref?.()
}
})
this.#drain()
return {
acquired,
cancel: () =>
this.#rejectWaiter(waiter, new Error(`Local backend start for "${key}" was cancelled while queued.`))
this.#rejectWaiter(waiter, new Error(`Local backend start for "${key}" was cancelled while queued.`)),
promote: (nextPriority: LocalBackendSpawnPriority) => this.#promoteWaiter(waiter, nextPriority),
queued: this.#queue.includes(waiter)
}
}
@@ -106,6 +149,45 @@ export class LocalBackendSpawnCoordinator {
return this.request(key).acquired
}
#timeoutError(waiter: Waiter): Error {
if (waiter.priority === 'background') {
return new LocalBackendSlotWaitTimeoutError(waiter.key, 'background')
}
return new Error(SLOT_WAIT_TIMEOUT_MESSAGE(waiter.key))
}
#backgroundLimit(): number {
return this.#limit >= 2 ? this.#limit - 1 : this.#limit
}
#canGrant(priority: LocalBackendSpawnPriority): boolean {
if (this.activeCount >= this.#limit) {
return false
}
if (priority === 'background' && this.#activeBackground >= this.#backgroundLimit()) {
return false
}
return true
}
#promoteWaiter(waiter: Waiter, priority: LocalBackendSpawnPriority): boolean {
if (!this.#queue.includes(waiter)) {
return false
}
if (waiter.priority === priority) {
return false
}
waiter.priority = priority
this.#drain()
return true
}
#rejectWaiter(waiter: Waiter, error: Error): boolean {
const index = this.#queue.indexOf(waiter)
@@ -127,8 +209,13 @@ export class LocalBackendSpawnCoordinator {
}
}
#grant(): ReleaseLocalBackendSlot {
this.#active += 1
#grant(priority: LocalBackendSpawnPriority): ReleaseLocalBackendSlot {
if (priority === 'background') {
this.#activeBackground += 1
} else {
this.#activeForeground += 1
}
let released = false
return () => {
@@ -137,17 +224,49 @@ export class LocalBackendSpawnCoordinator {
}
released = true
this.#active -= 1
if (priority === 'background') {
this.#activeBackground -= 1
} else {
this.#activeForeground -= 1
}
this.#drain()
}
}
/** Hand free slots to queued waiters while under the (possibly lowered) cap. */
#takeWaiter(priority: LocalBackendSpawnPriority): Waiter | undefined {
const index = this.#queue.findIndex(waiter => waiter.priority === priority)
if (index === -1) {
return undefined
}
return this.#queue.splice(index, 1)[0]
}
/** Hand free slots to queued waiters. Foreground waiters always go first. */
#drain(): void {
while (this.#active < this.#limit && this.#queue.length > 0) {
const next = this.#queue.shift()!
while (this.#canGrant('foreground')) {
const next = this.#takeWaiter('foreground')
if (!next) {
break
}
this.#clearTimer(next)
next.resolve(this.#grant())
next.resolve(this.#grant('foreground'))
}
while (this.#canGrant('background')) {
const next = this.#takeWaiter('background')
if (!next) {
break
}
this.#clearTimer(next)
next.resolve(this.#grant('background'))
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
// Launch-flag fact: the app was started with --local, so the renderer may
// show the local-models surfaces. Static for the window's lifetime.
localModelsEnabled: launchFlags?.localModels === true,
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts),
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
getProfileRoutes: profiles => ipcRenderer.invoke('hermes:plugin-profile-routes', profiles),
@@ -1849,3 +1849,66 @@ test('cleanupStale keeps the lockfile when even SIGKILL cannot confirm the pid d
// The record must survive so the next connect's reap pass retries.
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
})
test.skipIf(process.platform === 'win32')(
'buildSpawnCommand quotes expandRemotePath fragments exactly once (real sh parse)',
async () => {
// expandRemotePath() output is pre-quoted; a second shq() ships literal quote
// characters to the remote python. Parse the composed command with a real sh,
// as the remote login shell does, and require every path to come out clean.
const cmd = buildSpawnCommand('/x/hermes', 'work', {
hermesHome: '~/.hermes',
logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE),
ownershipId: OWNERSHIP_ID,
reservationNonce: SPAWN_NONCE,
spawnNonce: SPAWN_NONCE,
tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE),
lockMetadata: { ownershipId: OWNERSHIP_ID, spawnNonce: SPAWN_NONCE }
})
// Capture the argv a remote shell would hand to python3, via a shim on PATH.
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-argv-shim-'))
try {
const shimDir = path.join(root, 'shim')
const fakeHome = path.join(root, 'home')
await mkdir(shimDir)
await mkdir(fakeHome)
const argvFile = path.join(shimDir, 'argv')
await writeFile(path.join(shimDir, 'python3'), `#!/bin/sh\nprintf '%s\\0' "$@" > '${argvFile}'\n`, {
mode: 0o755
})
await exec(cmd, {
env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}`, HOME: fakeHome }
})
const argv = (await readFile(argvFile, 'utf8')).split('\0')
// argv: ['-c', <mutex script>, <mutex path>, <payload>]
assert.equal(
argv[2],
`${fakeHome}/.hermes/.hermes-update-in-progress.mutex`,
'mutex path must reach python fully expanded, with no quote characters'
)
// The payload assigns reservation/lock/owner_file before its mkdir loop.
// Evaluate only that prefix the way the remote sh does; never the loop itself.
const payload = argv[3]
const loopStart = payload.indexOf('i=0;')
assert.ok(loopStart > 0, 'payload prefix sentinel missing')
const { stdout } = await exec(
`${payload.slice(0, loopStart)} printf '%s\\n' "$reservation" "$lock" "$owner_file"`,
{
env: { ...process.env, HOME: fakeHome }
}
)
const [reservation, lock, ownerFile] = stdout.split('\n')
const base = `${fakeHome}/.hermes/desktop-ssh/${OWNERSHIP_ID}`
assert.equal(reservation, `${base}/.connect.lock`)
assert.equal(lock, `${base}/backend.lock.json`)
assert.equal(ownerFile, `${base}/.connect.lock/owner`)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
+4 -2
View File
@@ -895,7 +895,9 @@ finally:
// the marker check, spawns the backend, and publishes its initial lockfile.
// Python keeps the descriptor close-on-exec by default and passes it explicitly
// only to the intended outer shell; each detached child closes it before
// execing Hermes.
// execing Hermes. mutexPath is expandRemotePath() output — a complete shell
// word ("$HOME"'/…' or '/abs/…') embedded raw so $HOME expands remotely; a
// second shq() would hand python the quote characters as part of the path.
function withRemoteUpdateMutex(command, mutexPath) {
const script = `
import fcntl,os,subprocess,sys
@@ -913,7 +915,7 @@ finally:
sys.exit(result.returncode if result is not None else 1)
`.trim()
return `python3 -c ${shq(script)} ${shq(mutexPath)} ${shq(command)}`
return `python3 -c ${shq(script)} ${mutexPath} ${shq(command)}`
}
/**
@@ -4,6 +4,7 @@ import { test } from 'vitest'
import {
normalizeWindowConnectionRoute,
registrySshPoolScopeByConnectionId,
registrySshScopeForWindowRoute,
WindowConnectionRouteRegistry
} from './window-connection-route'
@@ -120,3 +121,20 @@ test('uses the canonical default profile scope when a registry SSH route has no
'conn:source-b::default'
)
})
test('recovers the bootstrap pool key a registry SSH tunnel was published under', () => {
const pool = new Map<string, any>([['', { registryConnectionId: 'source-b', ssh: { alive: true } }]])
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), '')
})
test('does not match another connection, an unlabelled entry, or a torn-down tunnel', () => {
const pool = new Map<string, any>([
['research', { registryConnectionId: 'source-a', ssh: { alive: true } }],
['', { registryConnectionId: '', ssh: { alive: true } }],
['worker', { registryConnectionId: 'source-b' }]
])
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-b'), null)
assert.equal(registrySshPoolScopeByConnectionId(pool, 'source-c'), null)
})
@@ -40,6 +40,30 @@ export function registrySshScopeForWindowRoute(
return backendScopeKey(route.connectionId, route.profile)
}
export interface RegistrySshPoolEntry {
registryConnectionId?: null | string
ssh?: unknown
}
// The sshConnections pool has a single writer that publishes every tunnel under
// its per-profile bootstrap key while stamping the entry with the registry
// connection that owns it. A registry-scoped lookup under the composite
// backendScopeKey can therefore miss a live tunnel; this recovers the writer's
// actual key from the stamped identity, the same match managedSshScopeRole
// applies to pool entries.
export function registrySshPoolScopeByConnectionId(
entries: Iterable<readonly [string, RegistrySshPoolEntry | undefined]>,
connectionId: string
): null | string {
for (const [scope, entry] of entries) {
if (entry?.ssh && entry.registryConnectionId === connectionId) {
return scope
}
}
return null
}
export class WindowConnectionRouteRegistry {
private readonly routes = new Map<number, WindowConnectionRoute>()
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { JSDOM } from 'jsdom'
import { afterEach, test, vi } from 'vitest'
// Execute the shipped page, including its inline script, rather than matching
// source strings or testing a second implementation of the progress client.
const html = fs.readFileSync(new URL('../../../scripts/desktop-update/ui.html', import.meta.url), 'utf8')
const windows = []
function openPage(fetch) {
vi.useFakeTimers()
const dom = new JSDOM(html, {
url: 'http://127.0.0.1:12345/',
runScripts: 'dangerously',
beforeParse(window) {
window.fetch = fetch
window.AbortController = AbortController
window.setTimeout = setTimeout
window.clearTimeout = clearTimeout
window.requestAnimationFrame = () => 1
window.cancelAnimationFrame = () => {}
}
})
windows.push(dom.window)
return dom.window.document
}
afterEach(() => {
windows.splice(0).forEach(window => window.close())
vi.useRealTimers()
})
test.each(['done', 'manual', 'error'])('renders %s before acknowledging terminal delivery', async status => {
let document
const requests = []
const receipt = '550e8400-e29b-41d4-a716-446655440000'
const fetch = vi.fn(async (url, options) => {
requests.push(url)
if (url.startsWith('/ack/')) {
assert.equal(options.method, 'POST')
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
return { ok: true }
}
return { ok: true, json: async () => ({ status, receipt, message: 'The updater result' }) }
})
document = openPage(fetch)
await vi.advanceTimersByTimeAsync(1000)
assert.equal(document.body.className, status === 'error' ? 'error' : 'done')
assert.notEqual(document.getElementById('title').textContent, 'Updating Hermes')
assert.deepEqual(requests, ['/progress', `/ack/${receipt}`])
})
test.each(['disconnect', 'hung', 'hung-body', 'http', 'invalid'])('bounds %s progress failures without inventing an update outcome', async failure => {
let attempts = 0
const fetch = vi.fn((_url, options) => {
attempts++
if (attempts === 1) {
return Promise.resolve({ ok: true, json: async () => ({ status: 'running', message: 'Installing dependencies' }) })
}
if (failure === 'hung' || failure === 'hung-body') {
const pending = () => new Promise((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
})
return failure === 'hung' ? pending() : Promise.resolve({ ok: true, json: pending })
}
if (failure === 'http') return Promise.resolve({ ok: false })
if (failure === 'invalid') return Promise.resolve({ ok: true, json: async () => ({}) })
return Promise.reject(new Error('connection refused'))
})
const document = openPage(fetch)
await vi.advanceTimersByTimeAsync(20_000)
assert.equal(document.body.className, 'disconnected')
assert.equal(document.getElementById('title').textContent, 'Update status unavailable')
assert.match(document.getElementById('line').textContent, /Check Hermes/)
assert.ok(attempts <= 4, `unbounded retry loop: ${attempts}`)
})
test.each(['legacy', 'transient', 'ack-failure'])('preserves terminal truth with %s servers', async mode => {
let attempts = 0
const fetch = vi.fn(async url => {
if (url.startsWith('/ack/')) throw new Error('server already stopped')
if (++attempts === 1 && mode === 'transient') throw new Error('temporary disconnect')
return { ok: true, json: async () => ({ status: 'done', ...(mode === 'ack-failure' ? { receipt: 'test-receipt' } : {}) }) }
})
const document = openPage(fetch)
await vi.advanceTimersByTimeAsync(20_000)
assert.equal(document.body.className, 'done')
assert.equal(document.getElementById('title').textContent, 'Update complete')
})
test('continues displaying a healthy long update while progress remains reachable', async () => {
const fetch = vi.fn(async () => ({ ok: true, json: async () => ({ status: 'running', message: 'Building Desktop' }) }))
const document = openPage(fetch)
await vi.advanceTimersByTimeAsync(60_000)
assert.equal(document.body.className, '')
assert.equal(document.getElementById('title').textContent, 'Updating Hermes')
assert.equal(document.getElementById('line').textContent, 'Building Desktop')
})
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { adoptSpokenReplySession, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
@@ -65,8 +65,15 @@ export function useComposerVoice({
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const ownsWakeIndicatorRef = useRef(false)
const previousSessionIdRef = useRef(sessionId)
const voiceStartRequest = useStore($voiceConversationStartRequest)
// eslint-disable-next-line no-restricted-syntax -- session-id adopt token, not an atom mirror
useEffect(() => {
adoptSpokenReplySession(previousSessionIdRef.current, sessionId)
previousSessionIdRef.current = sessionId
}, [sessionId])
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
maxRecordingSeconds,
@@ -0,0 +1,200 @@
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $previewStatusBySession } from '@/store/preview-status'
import { $sessionControlBySession, type SessionControlEntry } from '@/store/session-control'
import { $todosBySession } from '@/store/todos'
import { useSessionStatusPresence } from './use-status-presence'
const SID = 'presence-session-1'
const mockEntry = (overrides?: Partial<SessionControlEntry>): SessionControlEntry => ({
capability: 'supported',
error: null,
loading: false,
pendingAction: null,
snapshot: {
goal: null,
heartbeat: null,
loop: null,
revision: 'rev-1',
updated_at: 1000
},
...overrides
})
describe('useSessionStatusPresence', () => {
beforeEach(() => {
$todosBySession.set({})
$composerActionsBySession.set({})
$previewStatusBySession.set({})
$sessionControlBySession.set({})
})
afterEach(() => {
cleanup()
$todosBySession.set({})
$composerActionsBySession.set({})
$previewStatusBySession.set({})
$sessionControlBySession.set({})
})
it('returns false when session is null or empty', () => {
const { result } = renderHook(() => useSessionStatusPresence(null))
expect(result.current).toBe(false)
const { result: emptyResult } = renderHook(() => useSessionStatusPresence(SID))
expect(emptyResult.current).toBe(false)
})
it('returns true when legacy status items exist', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$todosBySession.set({
[SID]: [{ content: 'task 1', id: '1', status: 'in_progress' }]
})
})
expect(result.current).toBe(true)
})
it('returns true when structured goal exists in session control', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: {
goal: {
contract: {
boundaries: '',
constraints: '',
outcome: 'test outcome',
stop_when: '',
verification: ''
},
gates: [],
max_turns: 10,
status: 'active',
subgoals: [],
title: 'Structured Goal',
turns_used: 1
},
heartbeat: null,
loop: null,
revision: 'rev-2',
updated_at: 2000
}
})
})
})
expect(result.current).toBe(true)
})
it('returns true when structured loop exists in session control', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: {
goal: null,
heartbeat: null,
loop: {
awaiting_response: false,
created_at: 1000,
current_delay: 60,
deferred_by_goal: false,
interval_seconds: 60,
last_fired_at: 1000,
max_ticks: 10,
mode: 'interval',
next_due_at: 2000,
prompt: 'Run loop',
status: 'active',
ticks_fired: 0,
times: 5,
until: ''
},
revision: 'rev-3',
updated_at: 2000
}
})
})
})
expect(result.current).toBe(true)
})
it('returns true when structured heartbeat exists in session control', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: {
goal: null,
heartbeat: {
created_at: 1000,
fire_count: 1,
interval_seconds: 300,
last_fired_at: 1000,
prompt: 'Heartbeat check',
status: 'active'
},
loop: null,
revision: 'rev-4',
updated_at: 2000
}
})
})
})
expect(result.current).toBe(true)
})
it('returns false when session control entry has empty snapshot (null goal, loop, heartbeat)', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: {
goal: null,
heartbeat: null,
loop: null,
revision: 'rev-empty',
updated_at: 2000
}
})
})
})
expect(result.current).toBe(false)
})
it('returns true when session control entry has only an error', () => {
const { result } = renderHook(() => useSessionStatusPresence(SID))
expect(result.current).toBe(false)
act(() => {
$sessionControlBySession.set({
[SID]: mockEntry({
error: 'Gateway connection failed',
snapshot: null
})
})
})
expect(result.current).toBe(true)
})
})
@@ -3,8 +3,9 @@ import { useSyncExternalStore } from 'react'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { $sessionControlBySession } from '@/store/session-control'
/** Structural view of the three per-session feeds — they hold different item
/** Structural view of the per-session feeds — they hold different item
* types, and all this hook needs from each is "does this key have rows". */
interface PresenceFeed {
get(): Record<string, undefined | unknown[]>
@@ -14,7 +15,7 @@ interface PresenceFeed {
const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
const subscribe = (onChange: () => void) => {
const offs = FEEDS.map(feed => feed.listen(onChange))
const offs = [...FEEDS.map(feed => feed.listen(onChange)), $sessionControlBySession.listen(onChange)]
return () => {
for (const off of offs) {
@@ -24,8 +25,9 @@ const subscribe = (onChange: () => void) => {
}
/**
* Whether a session has any status items, micro actions, or previews, as a
* coarse *edge*: the boolean only flips when the stack appears/disappears.
* Whether a session has any status items, micro actions, previews, or
* structured session controls (goal, loop, heartbeat), as a coarse *edge*:
* the boolean only flips when the stack appears/disappears.
* ChatBar uses it to toggle a styling data-attr — subscribing to the whole
* `$statusItemsBySession` (a `computed` that rebuilds the entire map) /
* `$previewStatusBySession` maps re-rendered the ~1.4k ChatBar on every
@@ -39,6 +41,16 @@ export function useSessionStatusPresence(sessionId: string | null): boolean {
return false
}
return FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)
if (FEEDS.some(feed => (feed.get()[sessionId]?.length ?? 0) > 0)) {
return true
}
const control = $sessionControlBySession.get()[sessionId]
return Boolean(
control?.error ||
(control?.snapshot &&
(control.snapshot.goal !== null || control.snapshot.loop !== null || control.snapshot.heartbeat !== null))
)
})
}
@@ -1195,6 +1195,7 @@ export function ChatBar({
grows upward over the thread and the dock's own measurement covers
it. Collapses to nothing when every status is empty. */}
<ComposerStatusStack
onSubmit={onSubmit}
queue={
activeQueueSessionKey && queuedPrompts.length > 0 ? (
<QueuePanel
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
@@ -24,12 +25,14 @@ import {
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
import { refreshSessionGoal } from '@/store/goals'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { openSessionInNewWindow } from '@/store/windows'
import { PreviewStatusRow } from './preview-row'
import { SessionControlSections } from './session-control'
import { useSessionValue } from './session-control-utils'
import { StatusItemRow } from './status-row'
// Slow safety-net poll for silent exits (processes without notify_on_complete
@@ -74,6 +77,7 @@ const hasRunningTodo = (group: StatusGroup) =>
group.type === 'todo' && group.items.some(item => item.todoStatus === 'in_progress' && item.state === 'running')
interface ComposerStatusStackProps {
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
* as the last group so it stays fused to the composer like before. */
queue: ReactNode
@@ -85,7 +89,7 @@ interface ComposerStatusStackProps {
* every session-scoped status — subagents, background tasks, queue — grouped by
* type and separated by light dividers. Collapses to nothing when empty.
*/
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
// Subscribe to THIS session's slice only. Both maps churn on other
@@ -96,10 +100,22 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const controlEntry = useSessionValue($sessionControlBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
const groups = useMemo(() => groupStatusItems(items), [items])
const isStructuredSupported = controlEntry?.capability === 'supported'
const groups = useMemo(() => {
const raw = groupStatusItems(items)
if (isStructuredSupported) {
return raw.filter(g => g.type !== 'goal')
}
return raw
}, [items, isStructuredSupported])
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream. This must NOT reset
@@ -112,7 +128,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
useEffect(() => {
if (sessionId) {
void refreshBackgroundProcesses(sessionId)
void refreshSessionGoal(sessionId)
void refreshSessionControl(sessionId)
}
}, [sessionId])
@@ -160,6 +176,21 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}
const hasControlContent = Boolean(
controlEntry &&
(controlEntry.error ||
controlEntry.snapshot?.goal ||
controlEntry.snapshot?.loop ||
controlEntry.snapshot?.heartbeat)
)
if (sessionId && controlEntry && hasControlContent) {
sections.push({
key: 'session-control',
node: <SessionControlSections entry={controlEntry} onSubmit={onSubmit} sessionId={sessionId} />
})
}
for (const group of groups) {
sections.push({
key: group.type,
@@ -0,0 +1,639 @@
import { memo, useCallback, useState } from 'react'
import { queueKickoffIfSessionBusy } from '@/app/session/hooks/use-prompt-actions/queue-if-busy'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import {
runSessionControlAction,
type SessionControlAction,
type SessionControlActionArgs,
type SessionControlGoal
} from '@/store/session-control'
import type { ConfirmState } from './session-control-utils'
interface GoalSectionProps {
goal: SessionControlGoal
sessionId: string
pendingAction: SessionControlAction | null
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
onFeedback: (error: string | null, success: string | null) => void
}
export const SessionControlGoalSection = memo(function SessionControlGoalSection({
goal,
sessionId,
pendingAction,
onSubmit,
onFeedback
}: GoalSectionProps) {
const { t } = useI18n()
const s = t.statusStack
const ctrl = s.control
const [detailsOpen, setDetailsOpen] = useState(false)
const [addCriterionOpen, setAddCriterionOpen] = useState(false)
const [addCriterionError, setAddCriterionError] = useState<string | null>(null)
const [newCriterionText, setNewCriterionText] = useState('')
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const isBusy = Boolean(pendingAction)
const handleAction = useCallback(
async (
action: SessionControlAction,
args?: SessionControlActionArgs,
onFailure?: (message: string) => void
): Promise<boolean> => {
onFeedback(null, null)
try {
const dispatch = await runSessionControlAction(sessionId, action, args)
if (dispatch.type === 'send') {
if (!dispatch.message || !onSubmit) {
onFeedback(ctrl.continuationFailed, null)
onFailure?.(ctrl.continuationFailed)
return false
}
// The backend has already resumed the goal; if a turn is running the
// kickoff must queue (same as a typed `/goal resume`, slash.ts), not
// read as a failure.
const queued = queueKickoffIfSessionBusy({
displayText: dispatch.display ?? undefined,
sessionId,
text: dispatch.message
})
if (queued !== 'idle') {
const copy = queued === 'queued' ? ctrl.continuationQueued : ctrl.continuationBusy
onFeedback(queued === 'queued' ? null : copy, queued === 'queued' ? copy : null)
return queued === 'queued'
}
const submitted = await onSubmit(dispatch.message, {
displayKind: 'hidden',
sessionId
})
if (!submitted) {
onFeedback(ctrl.continuationFailed, null)
onFailure?.(ctrl.continuationFailed)
return false
}
}
onFeedback(null, ctrl.actionSucceeded)
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const failure = ctrl.actionFailed(msg)
onFeedback(failure, null)
onFailure?.(failure)
return false
}
},
[sessionId, onSubmit, onFeedback, ctrl]
)
const copyCriterionText = useCallback(
async (text: string) => {
try {
await navigator.clipboard.writeText(text)
onFeedback(null, ctrl.copySuccess)
} catch {
onFeedback(ctrl.copyFailure, null)
}
},
[onFeedback, ctrl]
)
const visibleState: 'waiting' | 'active' | 'paused' | 'done' = goal.wait_barrier
? 'waiting'
: goal.status === 'paused'
? 'paused'
: goal.status === 'done'
? 'done'
: 'active'
const iconClass =
goal.last_verdict === 'blocked'
? 'text-red-500'
: visibleState === 'done'
? 'text-muted-foreground/70'
: visibleState === 'active'
? 'text-emerald-500'
: 'text-amber-500'
const stateLabel =
goal.last_verdict === 'blocked'
? s.goalBlocked
: visibleState === 'waiting'
? s.goalWaiting
: visibleState === 'paused'
? s.goalPaused
: visibleState === 'done'
? s.goalDone
: s.goalActive
const headerLabel =
visibleState === 'done'
? `${stateLabel} · ${ctrl.goalDoneTurns(goal.turns_used)}`
: goal.max_turns > 0
? `${stateLabel} · ${ctrl.goalActiveTurns(goal.turns_used, goal.max_turns)}`
: goal.turns_used > 0
? `${stateLabel} · ${ctrl.goalTurn(goal.turns_used)}`
: stateLabel
const confirmClearGoal = () => {
setConfirmState({
title: ctrl.clearGoalConfirmTitle,
description: ctrl.clearGoalConfirmBody,
destructive: true,
confirmLabel: ctrl.clearGoal,
onConfirm: async () => {
await handleAction('goal.clear')
}
})
}
const confirmRemoveCriterion = (index: number) => {
setConfirmState({
title: ctrl.removeCriterionConfirmTitle(index),
description: ctrl.removeCriterionConfirmBody(index),
destructive: true,
confirmLabel: ctrl.removeCriterion(index),
onConfirm: async () => {
await handleAction('subgoal.remove', { index })
}
})
}
const confirmClearCriteria = () => {
setConfirmState({
title: ctrl.clearCriteriaConfirmTitle,
description: ctrl.clearCriteriaConfirmBody,
destructive: true,
confirmLabel: ctrl.clearCriteria,
onConfirm: async () => {
await handleAction('subgoal.clear')
}
})
}
const openAddCriterion = () => {
setAddCriterionError(null)
setAddCriterionOpen(true)
}
const renderMenuItems = (isContext = false) => {
const Item = isContext ? ContextMenuItem : DropdownMenuItem
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
return (
<>
{hasDetails && (
<Item disabled={isBusy} onSelect={() => setDetailsOpen(true)}>
<Codicon name="eye" size="0.8rem" />
<span>{ctrl.viewDetails}</span>
</Item>
)}
{visibleState !== 'done' && (
<Item disabled={isBusy} onSelect={openAddCriterion}>
<Codicon name="add" size="0.8rem" />
<span>{ctrl.addCriterion}</span>
</Item>
)}
{(hasDetails || visibleState !== 'done') && <Sep />}
{visibleState === 'active' && (
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
<Codicon name="debug-pause" size="0.8rem" />
<span>{ctrl.pauseGoal}</span>
</Item>
)}
{visibleState === 'paused' && (
<Item disabled={isBusy} onSelect={() => void handleAction('goal.resume')}>
<Codicon name="play" size="0.8rem" />
<span>{ctrl.resumeGoal}</span>
</Item>
)}
{visibleState === 'waiting' && (
<>
<Item disabled={isBusy} onSelect={() => void handleAction('goal.unwait')}>
<Codicon name="play" size="0.8rem" />
<span>{ctrl.resumeNow}</span>
</Item>
<Item disabled={isBusy} onSelect={() => void handleAction('goal.pause')}>
<Codicon name="debug-pause" size="0.8rem" />
<span>{ctrl.pauseGoal}</span>
</Item>
</>
)}
<Item disabled={isBusy} onSelect={confirmClearGoal} variant="destructive">
<Codicon name="trash" size="0.8rem" />
<span>{ctrl.clearGoal}</span>
</Item>
</>
)
}
const hasDetails = Boolean(
goal.contract.outcome ||
goal.contract.verification ||
goal.contract.constraints ||
goal.contract.boundaries ||
goal.contract.stop_when ||
goal.wait_barrier ||
goal.gates.length > 0
)
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div data-slot="session-control-goal">
<StatusSection
accessory={
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<Tip label={ctrl.goalActions}>
<span className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
aria-haspopup="menu"
aria-label={ctrl.goalActions}
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
disabled={isBusy}
onClick={event => {
// Radix opens pointer interactions from pointerdown. Keyboard,
// assistive-tech, and programmatic clicks have no pointer sequence.
if (event.detail === 0) {
setMenuOpen(true)
}
}}
onKeyDown={e => {
if (e.key === 'F10' && e.shiftKey) {
e.preventDefault()
setMenuOpen(true)
}
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="ellipsis" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
</span>
</Tip>
<DropdownMenuContent align="end" className="w-44">
{renderMenuItems(false)}
</DropdownMenuContent>
</DropdownMenu>
}
defaultCollapsed={false}
icon={<Codicon className={iconClass} name="target" size="0.8rem" />}
label={headerLabel}
>
<div className="space-y-1.5 px-1 py-1">
{/* Full goal title */}
<div className="text-xs font-normal leading-relaxed text-foreground/92 break-words">{goal.title}</div>
{/* Optional reasons */}
{!detailsOpen && goal.wait_barrier && (
<div className="text-[0.7rem] italic text-muted-foreground/80">
{goal.wait_barrier.reason
? `${ctrl.waitBarrierTitle}: ${goal.wait_barrier.reason}`
: ctrl.waitBarrierTitle}
</div>
)}
{!goal.wait_barrier && goal.paused_reason && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.paused_reason}</div>
)}
{!goal.wait_barrier && !goal.paused_reason && goal.last_reason && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{goal.last_reason}</div>
)}
{/* View details button */}
{hasDetails && (
<div>
<Button
className="text-[0.7rem] text-muted-foreground/75 hover:text-foreground/90"
onClick={() => setDetailsOpen(true)}
size="micro"
type="button"
variant="text"
>
{ctrl.viewDetails}
</Button>
</div>
)}
{/* Criteria subsection */}
<div className="mt-2 border-t border-(--ui-stroke-tertiary)/40 pt-1.5">
<div className="flex items-center justify-between pb-1 text-[0.68rem] font-medium text-muted-foreground/75">
<div className="flex items-center gap-1.5">
<span aria-hidden="true" className={`inline-flex ${iconClass}`} data-slot="criteria-state-marker">
<Codicon name="target" size="0.68rem" />
</span>
<span>{ctrl.criteriaHeader(goal.subgoals.length)}</span>
</div>
<div className="flex items-center gap-1">
<Button
className="text-[0.68rem] text-muted-foreground/75 hover:text-foreground/90"
disabled={isBusy}
onClick={openAddCriterion}
size="micro"
type="button"
variant="text"
>
{ctrl.addCriterion}
</Button>
{goal.subgoals.length > 0 && (
<Button
className="text-[0.68rem] text-muted-foreground/60 hover:text-destructive"
disabled={isBusy}
onClick={confirmClearCriteria}
size="micro"
type="button"
variant="text"
>
{ctrl.clearCriteria}
</Button>
)}
</div>
</div>
{goal.subgoals.length > 0 ? (
<div className="space-y-1">
{goal.subgoals.map((subgoal, idx) => {
const index = idx + 1
return (
<div
className="group/criterion flex items-start justify-between gap-1.5 rounded px-1 py-0.5 text-xs text-foreground/85 hover:bg-(--ui-control-active-background)/40"
key={`${index}-${subgoal}`}
>
<div className="flex min-w-0 flex-1 items-start gap-1.5 leading-relaxed">
<span className="shrink-0 text-muted-foreground/60 tabular-nums">{index}.</span>
<span className="break-words">{subgoal}</span>
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-80 group-hover/criterion:opacity-100">
<Tip label={ctrl.copyCriterion(index)}>
<Button
aria-label={ctrl.copyCriterion(index)}
className="size-6 rounded text-muted-foreground/60 hover:text-foreground/90"
onClick={() => void copyCriterionText(subgoal)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="copy" size="0.7rem" />
</Button>
</Tip>
<Tip label={ctrl.removeCriterion(index)}>
<Button
aria-label={ctrl.removeCriterion(index)}
className="size-6 rounded text-muted-foreground/60 hover:text-destructive"
disabled={isBusy}
onClick={() => confirmRemoveCriterion(index)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.7rem" />
</Button>
</Tip>
</div>
</div>
)
})}
</div>
) : null}
</div>
</div>
</StatusSection>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
</ContextMenu>
{/* Add Criterion Dialog */}
<Dialog
onOpenChange={open => {
if (!open) {
setAddCriterionOpen(false)
setAddCriterionError(null)
}
}}
open={addCriterionOpen}
>
<DialogContent className="max-w-md">
<form
onSubmit={async e => {
e.preventDefault()
const text = newCriterionText.trim()
if (!text || isBusy) {
return
}
setAddCriterionError(null)
const ok = await handleAction('subgoal.add', { text }, setAddCriterionError)
if (ok) {
setNewCriterionText('')
setAddCriterionOpen(false)
}
}}
>
<DialogHeader>
<DialogTitle>{ctrl.addCriterionDialogTitle}</DialogTitle>
<DialogDescription>{ctrl.addCriterionPlaceholder}</DialogDescription>
</DialogHeader>
<div className="py-2">
<textarea
aria-label={ctrl.criterionLabel}
autoFocus
className="w-full rounded-md border border-(--ui-stroke-secondary) bg-transparent p-2 text-xs text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary"
disabled={isBusy}
onChange={e => setNewCriterionText(e.target.value)}
placeholder={ctrl.addCriterionPlaceholder}
rows={3}
value={newCriterionText}
/>
</div>
{addCriterionError && (
<div className="text-xs text-destructive" role="alert">
{addCriterionError}
</div>
)}
<DialogFooter>
<Button disabled={isBusy} onClick={() => setAddCriterionOpen(false)} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={isBusy || !newCriterionText.trim()} type="submit">
{ctrl.add}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Goal Details Dialog */}
<Dialog onOpenChange={open => !open && setDetailsOpen(false)} open={detailsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{ctrl.goalDetailsTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2 text-xs">
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.objectiveLabel}
</div>
<div className="mt-0.5 text-foreground leading-relaxed break-words">{goal.title}</div>
</div>
{/* Contract fields */}
{goal.contract.outcome && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.contractOutcome}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.outcome}</div>
</div>
)}
{goal.contract.verification && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.contractVerification}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.verification}</div>
</div>
)}
{goal.contract.constraints && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.contractConstraints}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.constraints}</div>
</div>
)}
{goal.contract.boundaries && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.contractBoundaries}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.boundaries}</div>
</div>
)}
{goal.contract.stop_when && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.contractStopWhen}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">{goal.contract.stop_when}</div>
</div>
)}
{/* Wait Barrier */}
{goal.wait_barrier && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.waitBarrierTitle}
</div>
<div className="mt-0.5 text-foreground/90 leading-relaxed">
{goal.wait_barrier.type === 'until' &&
ctrl.waitUntil(new Date(goal.wait_barrier.until_at * 1000).toLocaleTimeString())}
{goal.wait_barrier.type === 'session' && ctrl.waitSession(goal.wait_barrier.target)}
{goal.wait_barrier.type === 'pid' && ctrl.waitPid(goal.wait_barrier.target)}
{goal.wait_barrier.reason && (
<div className="italic text-muted-foreground/80 mt-0.5">{goal.wait_barrier.reason}</div>
)}
</div>
</div>
)}
{/* Quality Gates */}
{goal.gates.length > 0 && (
<div>
<div className="font-semibold text-muted-foreground/75 uppercase tracking-wider text-[0.68rem]">
{ctrl.qualityGatesTitle}
</div>
<div className="mt-1 space-y-1.5">
{goal.gates.map((gate, i) => (
<div
className="rounded border border-(--ui-stroke-tertiary)/60 bg-(--ui-control-active-background)/20 p-2"
key={`${i}-${gate.command}`}
>
<div className="font-mono text-[0.7rem] text-foreground/95">{gate.command}</div>
<div className="mt-1 flex items-center gap-2 text-[0.68rem] text-muted-foreground/80">
<span>{ctrl.gateAttempts(gate.attempts, gate.max_retries)}</span>
<span className="text-muted-foreground/40">·</span>
<span>{ctrl.gateTimeout(gate.timeout_seconds)}</span>
<span className="text-muted-foreground/40">·</span>
<span>{ctrl.gateLastExit(gate.last_exit_code)}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => setDetailsOpen(false)} type="button">
{t.common.close}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmation Dialog */}
{confirmState && (
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={confirmState.confirmLabel}
description={confirmState.description}
destructive={confirmState.destructive}
onClose={() => setConfirmState(null)}
onConfirm={confirmState.onConfirm}
open={Boolean(confirmState)}
title={confirmState.title}
/>
)}
</>
)
})
@@ -0,0 +1,218 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import {
runSessionControlAction,
type SessionControlAction,
type SessionControlActionArgs,
type SessionControlHeartbeat
} from '@/store/session-control'
import { type ConfirmState, formatHeartbeatCountdown, formatHeartbeatInterval } from './session-control-utils'
interface HeartbeatSectionProps {
heartbeat: SessionControlHeartbeat
sessionId: string
pendingAction: SessionControlAction | null
onFeedback: (error: string | null, success: string | null) => void
}
function useHeartbeatClock(active: boolean): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!active) {
return
}
const tick = () => setNow(Date.now())
const interval = window.setInterval(tick, 1_000)
return () => window.clearInterval(interval)
}, [active])
return now
}
export const SessionControlHeartbeatSection = memo(function SessionControlHeartbeatSection({
heartbeat,
sessionId,
pendingAction,
onFeedback
}: HeartbeatSectionProps) {
const { t } = useI18n()
const s = t.statusStack
const ctrl = s.control
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const isBusy = Boolean(pendingAction)
const handleAction = useCallback(
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
onFeedback(null, null)
try {
await runSessionControlAction(sessionId, action, args)
onFeedback(null, ctrl.actionSucceeded)
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
onFeedback(ctrl.actionFailed(msg), null)
return false
}
},
[sessionId, onFeedback, ctrl]
)
const stateLabel = heartbeat.status === 'paused' ? ctrl.heartbeatPaused : ctrl.heartbeatActive
const now = useHeartbeatClock(heartbeat.status === 'active')
const intervalLabel = formatHeartbeatInterval(heartbeat.interval_seconds, t)
const nextDueTimestamp = (heartbeat.last_fired_at || heartbeat.created_at) + heartbeat.interval_seconds
const nextDueMs = nextDueTimestamp > 1e11 ? nextDueTimestamp : nextDueTimestamp * 1_000
const isDue = heartbeat.status === 'active' && nextDueMs <= now
const nextRunLabel =
heartbeat.status === 'active'
? ` · ${isDue ? ctrl.heartbeatDueWaitingForIdle : ctrl.heartbeatNext(formatHeartbeatCountdown(nextDueTimestamp, now))}`
: ''
const iconClass =
heartbeat.status === 'paused' ? 'text-red-500' : isDue ? 'text-amber-500' : 'text-emerald-500'
const headerLabel = `${stateLabel} · ${intervalLabel}${nextRunLabel}`
const confirmClearHeartbeat = () => {
setConfirmState({
title: ctrl.clearHeartbeatConfirmTitle,
description: ctrl.clearHeartbeatConfirmBody,
destructive: true,
confirmLabel: ctrl.clearHeartbeat,
onConfirm: async () => {
await handleAction('heartbeat.clear')
}
})
}
const renderMenuItems = (isContext = false) => {
const Item = isContext ? ContextMenuItem : DropdownMenuItem
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
return (
<>
{heartbeat.status === 'active' && (
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.pause')}>
<Codicon name="debug-pause" size="0.8rem" />
<span>{ctrl.pauseHeartbeat}</span>
</Item>
)}
{heartbeat.status === 'paused' && (
<Item disabled={isBusy} onSelect={() => void handleAction('heartbeat.resume')}>
<Codicon name="play" size="0.8rem" />
<span>{ctrl.resumeHeartbeat}</span>
</Item>
)}
<Sep />
<Item disabled={isBusy} onSelect={confirmClearHeartbeat} variant="destructive">
<Codicon name="trash" size="0.8rem" />
<span>{ctrl.clearHeartbeat}</span>
</Item>
</>
)
}
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div data-slot="session-control-heartbeat">
<StatusSection
accessory={
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<Tip label={ctrl.heartbeatActions}>
<span className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
aria-haspopup="menu"
aria-label={ctrl.heartbeatActions}
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
disabled={isBusy}
onClick={event => {
// Radix opens pointer interactions from pointerdown. Keyboard,
// assistive-tech, and programmatic clicks have no pointer sequence.
if (event.detail === 0) {
setMenuOpen(true)
}
}}
onKeyDown={e => {
if (e.key === 'F10' && e.shiftKey) {
e.preventDefault()
setMenuOpen(true)
}
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="ellipsis" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
</span>
</Tip>
<DropdownMenuContent align="end" className="w-44">
{renderMenuItems(false)}
</DropdownMenuContent>
</DropdownMenu>
}
defaultCollapsed={true}
icon={<Codicon className={iconClass} name="pulse" size="0.8rem" />}
label={headerLabel}
>
<div className="space-y-1 px-1 py-1 text-xs">
<div className="text-foreground/92 leading-relaxed break-words">{heartbeat.prompt}</div>
<div className="text-[0.7rem] text-muted-foreground/80">
<span>{ctrl.heartbeatFiredCount(heartbeat.fire_count)}</span>
</div>
</div>
</StatusSection>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">{renderMenuItems(true)}</ContextMenuContent>
</ContextMenu>
{confirmState && (
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={confirmState.confirmLabel}
description={confirmState.description}
destructive={confirmState.destructive}
onClose={() => setConfirmState(null)}
onConfirm={confirmState.onConfirm}
open={Boolean(confirmState)}
title={confirmState.title}
/>
)}
</>
)
})
@@ -0,0 +1,246 @@
import { memo, useCallback, useState } from 'react'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import {
runSessionControlAction,
type SessionControlAction,
type SessionControlActionArgs,
type SessionControlLoop
} from '@/store/session-control'
import { type ConfirmState, formatApproximateTime, formatInterval } from './session-control-utils'
interface LoopSectionProps {
loop: SessionControlLoop
sessionId: string
pendingAction: SessionControlAction | null
onFeedback: (error: string | null, success: string | null) => void
}
export const SessionControlLoopSection = memo(function SessionControlLoopSection({
loop,
sessionId,
pendingAction,
onFeedback
}: LoopSectionProps) {
const { t } = useI18n()
const s = t.statusStack
const ctrl = s.control
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const isBusy = Boolean(pendingAction)
const iconClass =
loop.status === 'done'
? 'text-muted-foreground/70'
: loop.status === 'paused' || loop.deferred_by_goal
? 'text-amber-500'
: 'text-emerald-500'
const handleAction = useCallback(
async (action: SessionControlAction, args?: SessionControlActionArgs): Promise<boolean> => {
onFeedback(null, null)
try {
await runSessionControlAction(sessionId, action, args)
onFeedback(null, ctrl.actionSucceeded)
return true
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
onFeedback(ctrl.actionFailed(msg), null)
return false
}
},
[sessionId, onFeedback, ctrl]
)
const stateLabel = loop.deferred_by_goal
? ctrl.loopDeferred
: loop.status === 'paused'
? ctrl.loopPaused
: loop.status === 'done'
? ctrl.loopFinished
: loop.awaiting_response
? `${ctrl.loopActive} · ${ctrl.loopAwaitingResponse}`
: ctrl.loopActive
const runCountLabel =
loop.times > 0 ? ctrl.loopRunCount(loop.ticks_fired, loop.times) : ctrl.loopRuns(loop.ticks_fired)
const nextRunLabel =
loop.status === 'active' && !loop.deferred_by_goal && loop.next_due_at
? ` · ${ctrl.loopNext(formatApproximateTime(loop.next_due_at))}`
: ''
const headerLabel = `${stateLabel} · ${runCountLabel}${nextRunLabel}`
const confirmStopLoop = () => {
if (loop.status === 'done') {
void handleAction('loop.stop')
return
}
setConfirmState({
title: ctrl.stopLoopConfirmTitle,
description: ctrl.stopLoopConfirmBody,
destructive: true,
confirmLabel: ctrl.stopLoop,
onConfirm: async () => {
await handleAction('loop.stop')
}
})
}
const renderMenuItems = (isContext = false) => {
const Item = isContext ? ContextMenuItem : DropdownMenuItem
const Sep = isContext ? ContextMenuSeparator : DropdownMenuSeparator
return (
<>
{loop.status === 'active' && (
<Item disabled={isBusy} onSelect={() => void handleAction('loop.pause')}>
<Codicon name="debug-pause" size="0.8rem" />
<span>{ctrl.pauseLoop}</span>
</Item>
)}
{loop.status === 'paused' && (
<Item disabled={isBusy} onSelect={() => void handleAction('loop.resume')}>
<Codicon name="play" size="0.8rem" />
<span>{ctrl.resumeLoop}</span>
</Item>
)}
{loop.status !== 'done' && <Sep />}
{loop.status === 'done' ? (
<Item disabled={isBusy} onSelect={() => void handleAction('loop.stop')}>
<Codicon name="close" size="0.8rem" />
<span>{ctrl.dismissLoop}</span>
</Item>
) : (
<Item disabled={isBusy} onSelect={confirmStopLoop} variant="destructive">
<Codicon name="stop-circle" size="0.8rem" />
<span>{ctrl.stopLoop}</span>
</Item>
)}
</>
)
}
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
<div data-slot="session-control-loop">
<StatusSection
accessory={
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<Tip label={ctrl.loopActions}>
<span className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
aria-haspopup="menu"
aria-label={ctrl.loopActions}
className="size-6 rounded-md text-muted-foreground/70 hover:text-foreground/90"
disabled={isBusy}
onClick={event => {
// Radix opens pointer interactions from pointerdown. Keyboard,
// assistive-tech, and programmatic clicks have no pointer sequence.
if (event.detail === 0) {
setMenuOpen(true)
}
}}
onKeyDown={e => {
if (e.key === 'F10' && e.shiftKey) {
e.preventDefault()
setMenuOpen(true)
}
}}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="ellipsis" size="0.8rem" />
</Button>
</DropdownMenuTrigger>
</span>
</Tip>
<DropdownMenuContent align="end" className="w-40">
{renderMenuItems(false)}
</DropdownMenuContent>
</DropdownMenu>
}
defaultCollapsed={true}
icon={<Codicon className={iconClass} name="sync" size="0.8rem" />}
label={headerLabel}
>
<div className="space-y-1 px-1 py-1 text-xs">
<div className="text-foreground/92 leading-relaxed break-words">{loop.prompt}</div>
<div className="text-[0.7rem] text-muted-foreground/80">
<span>
{ctrl.loopCadenceLabel}:{' '}
{loop.mode === 'self_paced' ? ctrl.loopSelfPaced : formatInterval(loop.interval_seconds, t)}
</span>
</div>
{loop.until && (
<div className="text-[0.7rem] text-muted-foreground/80">
<span>
{ctrl.loopUntilLabel}: {loop.until}
</span>
</div>
)}
{loop.deferred_by_goal && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopDeferredNotice}</div>
)}
{loop.awaiting_response && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{ctrl.loopAwaitingResponse}</div>
)}
{loop.paused_reason && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.paused_reason}</div>
)}
{loop.last_stop_reason && (
<div className="text-[0.7rem] italic text-muted-foreground/80">{loop.last_stop_reason}</div>
)}
</div>
</StatusSection>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-40">{renderMenuItems(true)}</ContextMenuContent>
</ContextMenu>
{confirmState && (
<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={confirmState.confirmLabel}
description={confirmState.description}
destructive={confirmState.destructive}
onClose={() => setConfirmState(null)}
onConfirm={confirmState.onConfirm}
open={Boolean(confirmState)}
title={confirmState.title}
/>
)}
</>
)
})
@@ -0,0 +1,96 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { Translations } from '@/i18n'
interface SessionSliceStore<T> {
get(): Record<string, T | undefined>
listen(listener: () => void): () => void
}
/** Subscribe to one stable per-session store entry without reacting to other sessions' writes. */
export function useSessionValue<T>(store: SessionSliceStore<T>, sessionId: string | null): T | undefined {
const subscribe = useCallback((onChange: () => void) => store.listen(onChange), [store])
return useSyncExternalStore(subscribe, () => (sessionId ? store.get()[sessionId] : undefined))
}
export interface ConfirmState {
title: string
description: string
destructive?: boolean
confirmLabel?: string
onConfirm: () => Promise<void> | void
}
export function formatApproximateTime(targetTimestamp: number): string {
if (!targetTimestamp) {
return ''
}
const targetMs = targetTimestamp > 1e11 ? targetTimestamp : targetTimestamp * 1000
const diffMs = targetMs - Date.now()
if (diffMs <= 0) {
return '~0s'
}
const seconds = Math.round(diffMs / 1000)
if (seconds < 60) {
return `~${seconds}s`
}
const minutes = Math.round(seconds / 60)
if (minutes < 60) {
return `~${minutes}m`
}
const hours = Math.round(minutes / 60)
if (hours < 24) {
return `~${hours}h`
}
const days = Math.round(hours / 24)
return `~${days}d`
}
/** Formats a countdown precisely without exposing the user's local timezone. */
export function formatHeartbeatCountdown(targetTimestamp: number, now = Date.now()): string {
const targetMs = targetTimestamp > 1e11 ? targetTimestamp : targetTimestamp * 1000
const remainingSeconds = Math.max(0, Math.ceil((targetMs - now) / 1000))
const days = Math.floor(remainingSeconds / 86_400)
const hours = Math.floor((remainingSeconds % 86_400) / 3_600)
const minutes = Math.floor((remainingSeconds % 3_600) / 60)
const seconds = remainingSeconds % 60
const padded = (value: number) => String(value).padStart(2, '0')
const clock = `${padded(hours)}:${padded(minutes)}:${padded(seconds)}`
return days > 0 ? `${days}d ${clock}` : clock
}
export function formatInterval(seconds: number, t: Translations): string {
if (seconds % 3600 === 0) {
return t.statusStack.control.loopEveryHours(seconds / 3600)
}
if (seconds % 60 === 0) {
return t.statusStack.control.loopEveryMinutes(seconds / 60)
}
return t.statusStack.control.loopEverySeconds(seconds)
}
export function formatHeartbeatInterval(seconds: number, t: Translations): string {
if (seconds % 3600 === 0) {
return t.statusStack.control.heartbeatEveryHours(seconds / 3600)
}
if (seconds % 60 === 0) {
return t.statusStack.control.heartbeatEveryMinutes(seconds / 60)
}
return t.statusStack.control.heartbeatEverySeconds(seconds)
}
@@ -0,0 +1,381 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GoalsModule from '@/store/goals'
import type * as SessionControlModule from '@/store/session-control'
const { mockRefreshSessionControl, mockRunSessionControlAction, mockRefreshSessionGoal } = vi.hoisted(() => ({
mockRefreshSessionControl: vi.fn(),
mockRunSessionControlAction: vi.fn(),
mockRefreshSessionGoal: vi.fn()
}))
vi.mock('@/store/session-control', async importOriginal => {
const actual = await importOriginal<typeof SessionControlModule>()
return {
...actual,
refreshSessionControl: mockRefreshSessionControl,
runSessionControlAction: mockRunSessionControlAction
}
})
vi.mock('@/store/goals', async importOriginal => {
const actual = await importOriginal<typeof GoalsModule>()
return {
...actual,
refreshSessionGoal: mockRefreshSessionGoal
}
})
import { I18nProvider } from '@/i18n'
import { clearQueuedPrompts, getQueuedPrompts } from '@/store/composer-queue'
import { $goalsBySession } from '@/store/goals'
import {
$sessionControlBySession,
type SessionControlEntry,
type SessionControlGoal,
type SessionControlHeartbeat,
type SessionControlLoop,
type SessionControlSnapshot
} from '@/store/session-control'
import { $sessionStates } from '@/store/session-states'
import { $todosBySession } from '@/store/todos'
import { ComposerStatusStack } from './index'
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const SID = 'sess-ctrl-1'
const sampleGoal = (overrides?: Partial<SessionControlGoal>): SessionControlGoal => ({
contract: {
boundaries: 'test boundaries',
constraints: 'test constraints',
outcome: 'test outcome',
stop_when: 'test stop condition',
verification: 'test verification'
},
gates: [
{
attempts: 1,
command: 'npm test',
last_exit_code: 0,
max_retries: 3,
timeout_seconds: 30
}
],
max_turns: 20,
status: 'active',
subgoals: ['First criterion', 'Second criterion'],
title: 'Execute complete work order',
turns_used: 3,
...overrides
})
const sampleLoop = (overrides?: Partial<SessionControlLoop>): SessionControlLoop => ({
awaiting_response: false,
created_at: 1700000000,
current_delay: 120,
deferred_by_goal: false,
interval_seconds: 120,
last_fired_at: 1700000000,
max_ticks: 10,
mode: 'interval',
next_due_at: 1700000120,
prompt: 'Check pending reviews',
status: 'active',
ticks_fired: 3,
times: 10,
until: '',
...overrides
})
const sampleHeartbeat = (overrides?: Partial<SessionControlHeartbeat>): SessionControlHeartbeat => ({
created_at: 1700000000,
fire_count: 4,
interval_seconds: 1800,
last_fired_at: 1700000000,
prompt: 'System health check',
status: 'active',
...overrides
})
const sampleSnapshot = (overrides?: Partial<SessionControlSnapshot>): SessionControlSnapshot => ({
goal: sampleGoal(),
heartbeat: null,
loop: null,
revision: 'rev-goal-1',
updated_at: 1700000000,
...overrides
})
const mockEntry = (overrides?: Partial<SessionControlEntry>): SessionControlEntry => ({
capability: 'supported',
error: null,
loading: false,
pendingAction: null,
snapshot: sampleSnapshot(),
...overrides
})
function renderStack(sessionId: null | string = SID, props: Record<string, unknown> = {}) {
return render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<ComposerStatusStack queue={null} sessionId={sessionId} {...props} />
</I18nProvider>
</MemoryRouter>
)
}
describe('ComposerStatusStack session-control UI', () => {
beforeEach(() => {
vi.clearAllMocks()
$goalsBySession.set({})
$sessionControlBySession.set({})
$todosBySession.set({})
mockRefreshSessionControl.mockResolvedValue(undefined)
mockRefreshSessionGoal.mockResolvedValue(undefined)
})
afterEach(() => {
cleanup()
$goalsBySession.set({})
$sessionControlBySession.set({})
$todosBySession.set({})
$sessionStates.set({})
clearQueuedPrompts('stored-1')
})
// 1. initial structured hydration on mount; old-gateway/legacy goal remains until supported
// 1. initial structured hydration on mount; old-gateway/legacy goal remains until supported
it('calls refreshSessionControl on mount and keeps legacy goal while capability is unknown/unsupported', () => {
$goalsBySession.set({
[SID]: { status: 'active', title: 'Legacy Goal Title', updatedAt: Date.now() }
})
$sessionControlBySession.set({
[SID]: { capability: 'unknown', error: null, loading: false, pendingAction: null, snapshot: null }
})
renderStack()
expect(mockRefreshSessionControl).toHaveBeenCalledWith(SID)
expect(mockRefreshSessionGoal).not.toHaveBeenCalled()
// Legacy goal is rendered while capability is unknown
expect(screen.getByText('Legacy Goal Title')).toBeTruthy()
})
// 2. supported structured goal replaces, not duplicates, legacy goal
it('replaces legacy goal when structured session control is supported without duplication', () => {
$goalsBySession.set({
[SID]: { status: 'active', title: 'Legacy Goal Title', updatedAt: Date.now() }
})
$sessionControlBySession.set({
[SID]: mockEntry({
capability: 'supported',
snapshot: sampleSnapshot({ goal: sampleGoal({ title: 'Structured Goal Title' }) })
})
})
renderStack()
expect(screen.getByText('Structured Goal Title')).toBeTruthy()
expect(screen.queryByText('Legacy Goal Title')).toBeNull()
})
// 11. loop pause/resume/stop calls exact action
it('calls loop.pause when pause loop is clicked', async () => {
mockRunSessionControlAction.mockResolvedValue({
type: 'exec',
output: 'Paused loop',
notice: null,
message: null,
display: null
})
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: sampleSnapshot({
goal: null,
loop: sampleLoop({ status: 'active' })
})
})
})
renderStack()
const loopMenuTrigger = screen.getByRole('button', { name: /loop actions/i })
fireEvent.click(loopMenuTrigger)
const pauseItem = await screen.findByRole('menuitem', { name: /pause loop/i })
fireEvent.click(pauseItem)
await waitFor(() => {
expect(mockRunSessionControlAction).toHaveBeenCalledWith(SID, 'loop.pause', undefined)
})
})
// 12. heartbeat interval/next/fire count and controls
it("shows a precise live countdown and makes an overdue heartbeat's idle wait explicit", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(1_700_000_030_000))
try {
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: sampleSnapshot({
goal: null,
heartbeat: sampleHeartbeat({
created_at: 1_700_000_000,
fire_count: 0,
interval_seconds: 90,
last_fired_at: 0,
status: 'active'
})
})
})
})
renderStack()
expect(screen.getByText(/Heartbeat active · every 90s · next 00:01:00/)).toBeTruthy()
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(screen.getByText(/Heartbeat active · every 90s · due — waiting for idle/)).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
// 13. action rejection produces alert/live feedback
it('displays an alert role when action rejects', async () => {
mockRunSessionControlAction.mockRejectedValue(new Error('Gateway rejected mutation'))
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: sampleSnapshot({
goal: sampleGoal({ status: 'active' })
})
})
})
renderStack()
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
fireEvent.click(menuTrigger)
const pauseItem = await screen.findByRole('menuitem', { name: /pause goal/i })
fireEvent.click(pauseItem)
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Gateway rejected mutation')
})
// 14. send dispatch submits exact hidden continuation via ChatBar callback and never calls a text slash path
it('submits hidden continuation on send dispatch through onSubmit callback', async () => {
const onSubmit = vi.fn().mockResolvedValue(true)
mockRunSessionControlAction.mockResolvedValue({
type: 'send',
message: 'Continue toward goal: verify tests',
output: null,
notice: null,
display: null
})
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: sampleSnapshot({
goal: sampleGoal({ status: 'paused' })
})
})
})
renderStack(SID, { onSubmit })
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
fireEvent.click(menuTrigger)
const resumeItem = await screen.findByRole('menuitem', { name: /resume goal/i })
fireEvent.click(resumeItem)
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith('Continue toward goal: verify tests', {
displayKind: 'hidden',
sessionId: SID
})
})
})
// 21. continuation failure when onSubmit returns false
it('reports continuation failure when onSubmit returns false or is missing', async () => {
const onSubmit = vi.fn().mockResolvedValue(false)
mockRunSessionControlAction.mockResolvedValue({
display: null,
message: 'Continue toward goal: run checks',
notice: null,
output: null,
type: 'send'
})
$sessionControlBySession.set({
[SID]: mockEntry({
snapshot: sampleSnapshot({ goal: sampleGoal({ status: 'paused' }) })
})
})
renderStack(SID, { onSubmit })
const menuTrigger = screen.getByRole('button', { name: /goal actions/i })
fireEvent.click(menuTrigger)
const resumeItem = await screen.findByRole('menuitem', { name: /resume goal/i })
fireEvent.click(resumeItem)
await waitFor(() => {
const alert = screen.getByRole('alert')
expect(alert.textContent).toContain('Failed to submit goal continuation')
})
})
it('queues the resume continuation instead of failing when the target session is busy', async () => {
const onSubmit = vi.fn().mockResolvedValue(false)
$sessionStates.set({ [SID]: { busy: true, storedSessionId: 'stored-1' } as never })
mockRunSessionControlAction.mockResolvedValue({
display: '/goal resume',
message: 'Continue toward goal: verify tests',
notice: null,
output: null,
type: 'send'
})
$sessionControlBySession.set({
[SID]: mockEntry({ snapshot: sampleSnapshot({ goal: sampleGoal({ status: 'paused' }) }) })
})
renderStack(SID, { onSubmit })
fireEvent.click(screen.getByRole('button', { name: /goal actions/i }))
fireEvent.click(await screen.findByRole('menuitem', { name: /resume goal/i }))
await waitFor(() => {
expect(getQueuedPrompts('stored-1')).toMatchObject([
{ displayText: '/goal resume', text: 'Continue toward goal: verify tests' }
])
})
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.queryByRole('alert')).toBeNull()
expect(screen.getByText(/continuation queued/i)).toBeTruthy()
})
})
@@ -0,0 +1,110 @@
import { memo, useCallback, useState } from 'react'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import type { SessionControlEntry } from '@/store/session-control'
import { SessionControlGoalSection } from './session-control-goal'
import { SessionControlHeartbeatSection } from './session-control-heartbeat'
import { SessionControlLoopSection } from './session-control-loop'
export interface SessionControlSectionsProps {
entry: SessionControlEntry
onSubmit?: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
sessionId: string
}
export const SessionControlSections = memo(function SessionControlSections({
entry,
onSubmit,
sessionId
}: SessionControlSectionsProps) {
const { t } = useI18n()
const ctrl = t.statusStack.control
const [feedbackError, setFeedbackError] = useState<string | null>(null)
const [feedbackSuccess, setFeedbackSuccess] = useState<string | null>(null)
const [dismissedStoreError, setDismissedStoreError] = useState<string | null>(null)
const handleFeedback = useCallback((error: string | null, success: string | null) => {
setFeedbackError(error)
setFeedbackSuccess(success)
}, [])
// Action failures arrive through onFeedback (which wins below); a store
// error with no feedback is a read/hydration failure, not a failed action.
const storeError = entry.error ? ctrl.controlUnavailable(entry.error) : null
const displayError = feedbackError ?? (entry.error === dismissedStoreError ? null : storeError)
const handleDismissError = useCallback(() => {
if (feedbackError) {
setFeedbackError(null)
} else {
setDismissedStoreError(entry.error)
}
}, [entry.error, feedbackError])
const snapshot = entry.snapshot
if (!snapshot && !displayError) {
return null
}
return (
<>
{displayError && (
<div
className="flex items-center justify-between gap-2 border-b border-destructive/20 bg-destructive/10 px-3 py-1.5 text-xs text-destructive"
role="alert"
>
<div className="flex items-center gap-1.5 truncate">
<Codicon className="shrink-0" name="error" size="0.85rem" />
<span className="truncate">{displayError}</span>
</div>
<Button
aria-label={ctrl.dismissError}
className="size-6 shrink-0 p-0 text-destructive hover:bg-destructive/20"
onClick={handleDismissError}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</div>
)}
{feedbackSuccess && (
<div aria-live="polite" className="sr-only">
{feedbackSuccess}
</div>
)}
{snapshot?.goal && (
<SessionControlGoalSection
goal={snapshot.goal}
onFeedback={handleFeedback}
onSubmit={onSubmit}
pendingAction={entry.pendingAction}
sessionId={sessionId}
/>
)}
{snapshot?.loop && (
<SessionControlLoopSection
loop={snapshot.loop}
onFeedback={handleFeedback}
pendingAction={entry.pendingAction}
sessionId={sessionId}
/>
)}
{snapshot?.heartbeat && (
<SessionControlHeartbeatSection
heartbeat={snapshot.heartbeat}
onFeedback={handleFeedback}
pendingAction={entry.pendingAction}
sessionId={sessionId}
/>
)}
</>
)
})
@@ -139,6 +139,7 @@ import {
ARTIFACTS_ROUTE,
CRON_ROUTE,
MESSAGING_ROUTE,
SESSION_IMPORT_ROUTE,
SIDEBAR_NAV_AREA,
type SidebarNavContribution,
SKILLS_ROUTE
@@ -225,6 +226,12 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
icon: props => <Codicon name="watch" {...props} />,
route: CRON_ROUTE,
keybindActionId: 'nav.cron'
},
{
id: 'session-import',
label: '',
icon: props => <Codicon name="cloud-download" {...props} />,
route: SESSION_IMPORT_ROUTE
}
]
@@ -1497,6 +1504,7 @@ export function ChatSidebar({
(item.id === 'messaging' && currentView === 'messaging') ||
(item.id === 'artifacts' && currentView === 'artifacts') ||
(item.id === 'cron' && currentView === 'cron') ||
(item.id === 'session-import' && currentView === 'session-import') ||
// Contributed rows light up at their own route.
(Boolean(item.route) && pathname === item.route)
@@ -13,6 +13,7 @@ import {
HUD_SURFACE,
HUD_TEXT
} from '@/app/floating-hud'
import { SESSION_IMPORT_ROUTE } from '@/app/routes'
import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
@@ -898,6 +899,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => void }) {
label: cc.sections.sessions,
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
{
icon: Download,
id: 'session-import',
keywords: ['import', 'claude', 'codex', 'conversation'],
label: t.sessionImport.action,
run: go(SESSION_IMPORT_ROUTE)
},
{
icon: Activity,
id: 'cc-system',
+13
View File
@@ -113,6 +113,7 @@ import {
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
import { SessionImportView } from '../session-import'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
@@ -1246,6 +1247,18 @@ export function ContribWiring({ children }: { children: ReactNode }) {
</Suspense>
)}
{currentView === 'session-import' && (
<SessionImportView
key={`${activeConnectionId}:${activeGatewayProfile}`}
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => {
closeOverlayToPreviousRoute()
openSession(sessionId, navigate, 'stack')
}}
owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }}
/>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
+5
View File
@@ -10,6 +10,7 @@ export const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SESSION_IMPORT_ROUTE = '/session-import'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
@@ -20,6 +21,7 @@ export const AGENTS_ROUTE = '/agents'
export const STARMAP_ROUTE = '/starmap'
export type AppView =
| 'session-import'
| 'agents'
| 'artifacts'
| 'chat'
@@ -38,6 +40,7 @@ export type AppView =
| 'webhooks'
export type AppRouteId =
| 'session-import'
| 'agents'
| 'artifacts'
| 'command-center'
@@ -57,6 +60,7 @@ export interface AppRoute {
}
export const APP_ROUTES = [
{ id: 'session-import', path: SESSION_IMPORT_ROUTE, view: 'session-import' },
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
@@ -123,6 +127,7 @@ export interface SidebarNavContribution {
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'session-import',
'agents',
'command-center',
'cron',
@@ -0,0 +1,49 @@
import { requestGatewayForAgent } from '@/store/gateway'
import type { SessionOwnerRoute } from '@/store/session-request-router'
export interface ForeignSession {
id: string
source: 'claude' | 'codex'
label: string
title: string
cwd: string | null
mtime: number
turn_count: number
excerpt: string
}
export interface ForeignPage {
sessions: ForeignSession[]
next_offset: number | null
host: string
unreadable: number
}
export interface ForeignPreview {
truncated: boolean
messages: { role: string; content: string }[]
total: number
already_imported: string | null
cwd: string | null
}
export interface ForeignImportResult {
session_id: string
already_imported: boolean
}
export function foreignRequest<T>(
owner: SessionOwnerRoute,
method: 'list' | 'preview' | 'import',
params: Record<string, unknown>,
signal?: AbortSignal
) {
return requestGatewayForAgent<T>(
owner.connectionId,
owner.profile,
`session.foreign.${method}`,
params,
60_000,
signal
)
}
@@ -0,0 +1,102 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { setSessionOwnerHint } from '@/store/session'
import { type ForeignImportResult, foreignRequest } from './api'
import { SessionImportView } from './index'
vi.mock('./api', () => ({ foreignRequest: vi.fn() }))
vi.mock('@/store/session', () => ({ setSessionOwnerHint: vi.fn() }))
vi.mock('@/components/assistant-ui/markdown-text', () => ({
MarkdownTextContent: ({ text }: { text: string }) => <p>{text}</p>
}))
vi.mock('../overlays/overlay-view', () => ({
OverlayView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
const owner = { connectionId: 'workstation', profile: 'research' }
const session = {
id: 'foreign-one',
source: 'claude',
label: 'Claude Code',
title: 'Repair imports',
cwd: '/work/project',
mtime: 1000,
turn_count: 2,
excerpt: 'Help with imports'
}
function mount(onOpenSession = vi.fn()) {
return {
onOpenSession,
...render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<SessionImportView onClose={vi.fn()} onOpenSession={onOpenSession} owner={owner} />
</QueryClientProvider>
)
}
}
it('browses without importing, then retries a failed import on the captured owner before opening', async () => {
let attempts = 0
vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
if (method === 'list') {
return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
}
if (method === 'preview') {
return { messages: [{ role: 'user', content: 'Please repair this' }], total: 1, already_imported: null }
}
if (++attempts === 1) {
throw new Error('Connection interrupted')
}
return { session_id: 'durable-one', already_imported: false }
})
const { onOpenSession } = mount()
fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
await screen.findByText('Please repair this')
expect(attempts).toBe(0)
fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
await screen.findByRole('alert')
expect(onOpenSession).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
await waitFor(() => expect(onOpenSession).toHaveBeenCalledWith('durable-one'))
expect(setSessionOwnerHint).toHaveBeenCalledWith('durable-one', owner)
expect(foreignRequest).toHaveBeenCalledWith(owner, 'import', { id: 'foreign-one' }, expect.any(AbortSignal))
})
it('does not navigate when an import finishes after the view has closed', async () => {
let finish!: (result: ForeignImportResult) => void
vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
if (method === 'list') {
return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
}
if (method === 'preview') {
return { messages: [], total: 0, already_imported: 'existing' }
}
return new Promise<ForeignImportResult>(resolve => {
finish = resolve
})
})
const { onOpenSession, unmount } = mount()
fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Open in Hermes' }))
await waitFor(() => expect(finish).toBeDefined())
unmount()
finish({ session_id: 'existing', already_imported: true })
await waitFor(() => expect(setSessionOwnerHint).toHaveBeenCalledWith('existing', owner))
expect(onOpenSession).not.toHaveBeenCalled()
})
@@ -0,0 +1,320 @@
import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { EmptyState } from '@/components/ui/empty-state'
import { ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { SearchField } from '@/components/ui/search-field'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { setSessionOwnerHint } from '@/store/session'
import type { SessionOwnerRoute } from '@/store/session-request-router'
import { OverlayView } from '../overlays/overlay-view'
import { PanelEmpty } from '../overlays/panel'
import { type ForeignImportResult, type ForeignPage, type ForeignPreview, foreignRequest } from './api'
interface SessionImportViewProps {
owner: SessionOwnerRoute
onClose: () => void
onOpenSession: (id: string) => void
}
export function SessionImportView({ owner, onClose, onOpenSession }: SessionImportViewProps) {
const { t, locale } = useI18n()
const copy = t.sessionImport
const queryClient = useQueryClient()
const [source, setSource] = useState<'all' | 'claude' | 'codex'>('all')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const lifetime = useRef<AbortController | null>(null)
// eslint-disable-next-line no-restricted-syntax -- lifetime cancellation, not a mirrored reactive value
useEffect(() => {
const controller = new AbortController()
lifetime.current = controller
return () => controller.abort()
}, [])
const scope = [owner.connectionId, owner.profile]
const sessions = useInfiniteQuery({
queryKey: ['foreign-sessions', ...scope, source],
initialPageParam: 0,
queryFn: ({ pageParam, signal }) =>
foreignRequest<ForeignPage>(
owner,
'list',
{
source: source === 'all' ? null : source,
offset: pageParam
},
signal
),
getNextPageParam: page => page.next_offset,
retry: false
})
const rows = [
...new Map((sessions.data?.pages.flatMap(page => page.sessions) ?? []).map(row => [row.id, row])).values()
]
const visible = rows.filter(row =>
`${row.title} ${row.cwd ?? ''} ${row.excerpt}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())
)
const current = rows.find(row => row.id === selected)
const preview = useQuery({
queryKey: ['foreign-preview', ...scope, selected],
queryFn: ({ signal }) => foreignRequest<ForeignPreview>(owner, 'preview', { id: selected }, signal),
enabled: Boolean(current),
retry: false
})
const host = sessions.data?.pages[0]?.host
const unreadable = sessions.data?.pages.reduce((sum, page) => sum + page.unreadable, 0) ?? 0
async function continueSession() {
if (!current || pending) {
return
}
const signal = lifetime.current?.signal
setPending(true)
setError('')
try {
const result = await foreignRequest<ForeignImportResult>(owner, 'import', { id: current.id }, signal)
setSessionOwnerHint(result.session_id, owner)
void queryClient.invalidateQueries({ queryKey: ['foreign-preview', ...scope] })
void queryClient.invalidateQueries({ queryKey: ['sessions'] })
if (!signal?.aborted) {
onOpenSession(result.session_id)
}
} catch (cause) {
if (!signal?.aborted) {
setError(cause instanceof Error ? cause.message : copy.importError)
}
} finally {
if (!signal?.aborted) {
setPending(false)
}
}
}
return (
<OverlayView
onClose={onClose}
rootClassName="bg-(--ui-bg-elevated)"
titlebarActions={
<Button
aria-label={t.common.refresh}
disabled={sessions.isFetching}
onClick={() => {
void sessions.refetch()
if (current) {
void preview.refetch()
}
}}
size="icon-titlebar"
variant="ghost"
>
<Codicon name="refresh" />
</Button>
}
>
<section aria-label={copy.title} className="session-import flex h-full min-h-0 flex-col">
<header className="px-8 pb-6 pt-12">
<h1 className="text-3xl font-semibold tracking-tight text-(--ui-text-primary)">{copy.title}</h1>
<p className="mt-2 text-sm text-(--ui-text-secondary)">{copy.subtitle}</p>
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-(--ui-text-secondary)">
<span className="inline-flex items-center gap-2">
<Codicon name="device-desktop" />
{copy.readingFrom} {host ?? copy.connectedComputer}
</span>
<span className="inline-flex items-center gap-2">
<Codicon name="account" />
{copy.destination} {owner.targetProfile ?? owner.profile}
</span>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(17rem,0.85fr)_minmax(0,1.6fr)] border-t border-(--ui-stroke-tertiary) max-[760px]:grid-cols-1">
<aside
className={cn(
'flex min-h-0 flex-col bg-(--ui-sidebar-surface-background)',
selected && 'max-[760px]:hidden'
)}
>
<div className="flex flex-col gap-5 px-6 pb-4 pt-6">
<SegmentedControl
onChange={value => {
setSource(value)
setSelected(null)
setError('')
}}
options={[
{ id: 'all', label: copy.all },
{ id: 'claude', label: 'Claude Code' },
{ id: 'codex', label: 'Codex' }
]}
value={source}
/>
{rows.length > 0 && (
<SearchField
aria-label={copy.search}
containerClassName="opacity-100"
onChange={setSearch}
placeholder={copy.search}
value={search}
/>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-5">
{sessions.isPending && <Loader className="mx-auto my-12" label={copy.scanning} />}
{sessions.isError && (
<ErrorState className="p-5" description={copy.scanHelp} title={copy.scanError}>
<Button onClick={() => void sessions.refetch()} variant="secondary">
{t.common.retry}
</Button>
</ErrorState>
)}
{!sessions.isPending && !sessions.isError && visible.length === 0 && (
<div className="p-5">
<EmptyState
description={search ? copy.searchHelp : copy.emptyHelp}
title={search ? copy.noMatches : copy.empty}
/>
</div>
)}
{visible.map((row, index) => {
const date = new Date(row.mtime * 1000).toLocaleDateString(locale, {
month: 'short',
day: 'numeric'
})
const previous = visible[index - 1]
const newDate =
!previous ||
new Date(previous.mtime * 1000).toDateString() !== new Date(row.mtime * 1000).toDateString()
return (
<div key={row.id}>
{newDate && <p className="px-3 pb-2 pt-5 text-xs font-medium text-(--ui-text-secondary)">{date}</p>}
<button
aria-pressed={selected === row.id}
className={cn(
'w-full cursor-pointer rounded-md px-3 py-4 text-start transition-colors hover:bg-(--chrome-action-hover) focus-visible:outline-2 focus-visible:outline-(--ui-accent)',
selected === row.id && 'bg-(--chrome-action-hover)'
)}
onClick={() => {
setSelected(row.id)
setError('')
}}
type="button"
>
<span className="mb-2 flex items-center gap-2 text-xs text-(--ui-text-secondary)">
<Codicon name="comment-discussion" />
{row.label}
<span className="ms-auto tabular-nums">
{row.turn_count} {copy.messages}
</span>
</span>
<span className="line-clamp-2 text-sm font-medium leading-5 text-(--ui-text-primary)">
{row.title}
</span>
<span className="mt-1 block truncate text-xs leading-5 text-(--ui-text-secondary)">
{row.cwd || row.excerpt}
</span>
</button>
</div>
)
})}
{unreadable > 0 && <p className="px-3 py-4 text-xs text-(--ui-text-secondary)">{copy.skipped}</p>}
{sessions.hasNextPage && (
<div className="flex justify-center pt-4">
<Button
disabled={sessions.isFetchingNextPage}
onClick={() => void sessions.fetchNextPage()}
variant="text"
>
{copy.more}
</Button>
</div>
)}
</div>
</aside>
<main className={cn('flex min-h-0 min-w-0 flex-col', !selected && 'max-[760px]:hidden')}>
{!current ? (
<PanelEmpty description={copy.chooseHelp} icon="comment-discussion" title={copy.choose} />
) : (
<>
<div className="px-8 pb-5 pt-6">
<div className="mb-4 hidden max-[760px]:block">
<Button onClick={() => setSelected(null)} variant="text">
{t.common.back}
</Button>
</div>
<h2 className="text-xl font-medium leading-7 tracking-tight">{current.title}</h2>
<p className="mt-2 text-xs text-(--ui-text-secondary)">
{current.label} · {current.turn_count} {copy.messages}
</p>
</div>
<div aria-live="polite" className="min-h-0 flex-1 overflow-y-auto px-8 pb-8" key={current.id}>
{preview.isPending && <Loader className="mx-auto my-12" label={copy.previewLoading} />}
{preview.isError && (
<ErrorState description={copy.previewHelp} title={copy.previewError}>
<Button onClick={() => void preview.refetch()} variant="secondary">
{t.common.retry}
</Button>
</ErrorState>
)}
{preview.data && (
<div className="max-w-prose space-y-7">
{preview.data.truncated && (
<p className="text-xs text-(--ui-text-secondary)">{copy.previewLimit}</p>
)}
{preview.data.messages.map((message, index) => (
<article className="min-w-0" key={index}>
<p className="mb-2 text-xs font-semibold text-(--ui-text-secondary)">
{message.role === 'user' ? copy.you : current.label}
</p>
<div className="text-sm leading-6 wrap-anywhere">
<MarkdownTextContent isRunning={false} previewOnly text={message.content} />
</div>
</article>
))}
</div>
)}
</div>
<footer className="flex flex-wrap items-center justify-between gap-4 border-t border-(--ui-stroke-tertiary) px-8 py-5">
<p className="max-w-xs text-xs leading-5 text-(--ui-text-secondary)">
{preview.data?.already_imported ? copy.snapshot : copy.copyNotice}
</p>
<Button disabled={pending || !preview.data || preview.isError} onClick={() => void continueSession()}>
{pending ? copy.importing : preview.data?.already_imported ? copy.open : copy.continue}
<Codicon name="arrow-right" />
</Button>
{error && (
<div className="w-full text-sm text-destructive" role="alert">
{copy.importError} {error}
</div>
)}
</footer>
</>
)}
</main>
</div>
</section>
</OverlayView>
)
}
@@ -20,6 +20,7 @@ import { handleDesktopBridgeEvent } from './desktop-bridge'
import { handleInputRequestEvent } from './input-requests'
import { handleLifecycleEvent } from './lifecycle'
import { handleMessageStreamEvent } from './message-stream'
import { handleControlEvent } from './session-control'
import { handleSessionInfoEvent } from './session-info'
import { handleStatusEvent } from './status'
import { handleToolEvent } from './tools'
@@ -84,6 +85,7 @@ const PROVIDER_WAIT_SUPERSEDING_EVENT_TYPES = new Set([
const HANDLERS: GatewayEventHandler[] = [
handleLifecycleEvent,
handleSessionInfoEvent,
handleControlEvent,
handleMessageStreamEvent,
handleToolEvent,
handleInputRequestEvent,
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
const { refreshSupportedSessionControlAfterTurn } = vi.hoisted(() => ({
refreshSupportedSessionControlAfterTurn: vi.fn(async () => undefined)
}))
vi.mock('@/store/session-control', () => ({ refreshSupportedSessionControlAfterTurn }))
import { handleMessageStreamEvent } from './message-stream'
import type { GatewayEventContext } from './types'
function context(type: string): GatewayEventContext {
return {
deps: {
activeGatewayProfile: 'default',
activeSessionIdRef: { current: 's1' },
appendAssistantDelta: vi.fn(),
appendReasoningDelta: vi.fn(),
compactedTurnRef: { current: new Set() },
completeAssistantMessage: vi.fn(),
failAssistantMessage: vi.fn(),
finalizeInterimAssistantMessage: vi.fn(),
flushQueuedDeltas: vi.fn(),
hydrateFromStoredSession: vi.fn(async () => undefined),
lastCwdInfoSessionRef: { current: null },
nativeSubagentSessionsRef: { current: new Set() },
queryClient: {} as GatewayEventContext['deps']['queryClient'],
refreshHermesConfig: vi.fn(async () => undefined),
scheduleSessionsRefresh: vi.fn(),
sessionInterrupted: vi.fn(() => false),
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn(),
upsertToolCall: vi.fn()
},
event: { type },
explicitSid: 's1',
fromActiveSource: () => true,
isActiveEvent: false,
occurredAt: 1_700_000_100,
payload: { text: 'completed' },
scheduleConfigRefresh: vi.fn(),
sessionId: 's1'
}
}
describe('handleMessageStreamEvent session-control integration', () => {
it('refreshes only after message.complete, through the store seam', () => {
expect(handleMessageStreamEvent(context('message.delta'))).toBe(true)
expect(refreshSupportedSessionControlAfterTurn).not.toHaveBeenCalled()
expect(handleMessageStreamEvent(context('message.complete'))).toBe(true)
expect(refreshSupportedSessionControlAfterTurn).toHaveBeenCalledTimes(1)
expect(refreshSupportedSessionControlAfterTurn).toHaveBeenCalledWith('s1')
})
})
@@ -14,6 +14,7 @@ import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { clearAllPrompts } from '@/store/prompts'
import { providerWaitText, setSessionProviderWait } from '@/store/provider-wait'
import { setCurrentUsage, setTurnStartedAt } from '@/store/session'
import { refreshSupportedSessionControlAfterTurn } from '@/store/session-control'
import { pruneFinishedSessionSubagents } from '@/store/subagents'
import { clearActiveSessionTodos } from '@/store/todos'
@@ -388,6 +389,10 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
}
}
// Refresh only the structured-control sessions already proven capable.
// Initial hydration owns the unknown capability probe.
void refreshSupportedSessionControlAfterTurn(sessionId)
return true
}
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $sessionControlBySession, clearAllSessionControl, type SessionControlSnapshot } from '@/store/session-control'
import { handleControlEvent } from './session-control'
import type { GatewayEventContext } from './types'
const SNAPSHOT: SessionControlSnapshot = {
goal: null,
heartbeat: null,
loop: null,
revision: 'event-revision',
updated_at: 1_700_000_100
}
function context(overrides: Partial<GatewayEventContext> = {}): GatewayEventContext {
return {
deps: {} as GatewayEventContext['deps'],
event: { payload: { control: SNAPSHOT }, session_id: 'event-session', type: 'session.control.update' },
explicitSid: 'event-session',
fromActiveSource: () => true,
isActiveEvent: true,
occurredAt: 1_700_000_100,
payload: { control: SNAPSHOT } as GatewayEventContext['payload'],
scheduleConfigRefresh: vi.fn(),
sessionId: 'routed-session',
...overrides
}
}
describe('handleControlEvent', () => {
afterEach(() => {
clearAllSessionControl()
})
it('does not claim non-control events', () => {
expect(handleControlEvent(context({ event: { type: 'message.complete' } }))).toBe(false)
})
it('applies a valid event to the routed session and proves it supported', () => {
expect(handleControlEvent(context())).toBe(true)
expect($sessionControlBySession.get()).toMatchObject({
'routed-session': { capability: 'supported', snapshot: { revision: 'event-revision' } }
})
expect($sessionControlBySession.get()['event-session']).toBeUndefined()
})
it('claims malformed control events without replacing the last good state', () => {
handleControlEvent(context())
const first = $sessionControlBySession.get()['routed-session']
expect(
handleControlEvent(
context({
payload: { control: { ...SNAPSHOT, updated_at: Number.POSITIVE_INFINITY } } as GatewayEventContext['payload']
})
)
).toBe(true)
expect($sessionControlBySession.get()['routed-session']).toBe(first)
})
it('claims an unscoped control event without creating state', () => {
expect(handleControlEvent(context({ sessionId: null }))).toBe(true)
expect($sessionControlBySession.get()).toEqual({})
})
})
@@ -0,0 +1,20 @@
import { applySessionControlUpdate } from '@/store/session-control'
import type { GatewayEventContext } from './types'
export function handleControlEvent(ctx: GatewayEventContext): boolean {
const { event, payload, sessionId } = ctx
if (event.type !== 'session.control.update') {
return false
}
if (!sessionId) {
return true
}
const control = payload && typeof payload === 'object' ? (payload as { control?: unknown }).control : undefined
applySessionControlUpdate(sessionId, control)
return true
}
@@ -0,0 +1,45 @@
import { enqueueQueuedPrompt } from '@/store/composer-queue'
import { $sessions, resolveComposerSessionKey } from '@/store/session'
import { $sessionStates } from '@/store/session-states'
import { isTargetSessionBusy } from './utils'
export interface QueueIfBusyInput {
/** Runtime session the command was resolved against. */
sessionId: string
/** Stored session the composer queue is keyed by; defaults to the runtime id
* (or its published storedSessionId when known). */
storedSessionId?: string | null
/** Foreground busy flag — only consulted when there is no session id. */
foregroundBusy?: boolean
text: string
displayText?: string
}
/**
* When the target session is mid-turn, park a backend-produced kickoff on the
* composer queue instead of dropping it. The backend has ALREADY executed the
* command (a `/goal` is set, a goal is resumed) and `text` is the prompt that
* tells the agent about it — losing it leaves the goal silently unheard
* (#63352). Returns `'idle'` when the caller should submit normally, `'queued'`
* when the prompt now waits on the queue, and `'busy'` when the session is
* busy but nothing could be queued.
*/
export function queueKickoffIfSessionBusy({
displayText,
foregroundBusy = false,
sessionId,
storedSessionId,
text
}: QueueIfBusyInput): 'busy' | 'idle' | 'queued' {
const states = $sessionStates.get()
if (!isTargetSessionBusy(states, sessionId, foregroundBusy)) {
return 'idle'
}
const stored = storedSessionId ?? states[sessionId]?.storedSessionId ?? null
const queueKey = resolveComposerSessionKey(stored, $sessions.get()) || stored || sessionId
return enqueueQueuedPrompt(queueKey, { attachments: [], displayText, text }) ? 'queued' : 'busy'
}
@@ -18,7 +18,6 @@ import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import { setComposerDraft } from '@/store/composer'
import { enqueueQueuedPrompt } from '@/store/composer-queue'
import { applyGoalStatusText } from '@/store/goals'
import { dismissNotification, notify, notifyError } from '@/store/notifications'
import { setPetScale } from '@/store/pet-gallery'
@@ -34,7 +33,6 @@ import {
$connection,
$sessions,
$yoloActive,
resolveComposerSessionKey,
setActiveSessionId,
setCurrentUsage,
setModelPickerOpen,
@@ -61,11 +59,11 @@ import type {
SlashExecResponse
} from '../../../types'
import { queueKickoffIfSessionBusy } from './queue-if-busy'
import { resolveTargetSessionId } from './resolve-target-session'
import {
type GatewayRequest,
isSessionIdCandidate,
isTargetSessionBusy,
renderCommandsCatalog,
renderRpcResult,
slashStatusText,
@@ -345,25 +343,25 @@ export function useSlashCommand(deps: SlashCommandDeps) {
// view's — see isTargetSessionBusy. `busyRef` mirrors whatever chat
// is on screen, while this command runs against the session
// resolveTargetSessionId picked, routinely a different one.
if (isTargetSessionBusy($sessionStates.get(), sessionId, busyRef.current)) {
// The backend already executed the command — for `/goal <text>`
// the goal is set and `message` is its kickoff prompt. Dropping
// it here loses the kickoff silently (the goal exists but the
// agent never hears about it, #63352). Queue it on the composer
// queue instead: it fires when the running turn settles, and the
// queue panel above the composer shows it in the meantime.
//
// Park it on the same stored session the output writer is bound to
// rather than re-reading the globals here — a session switch between
// dispatch and this branch would otherwise queue the kickoff on
// whichever chat is now in front.
const queueKey = resolveComposerSessionKey(storedSessionId, $sessions.get()) || storedSessionId || sessionId
//
// Parked on the same stored session the output writer is bound to
// rather than re-reading the globals — a session switch between
// dispatch and this branch would otherwise queue the kickoff on
// whichever chat is now in front (#63352).
const queued = queueKickoffIfSessionBusy({
displayText,
foregroundBusy: busyRef.current,
sessionId,
storedSessionId,
text: message
})
if (enqueueQueuedPrompt(queueKey, { attachments: [], text: message, displayText })) {
renderSlashOutput('session busy — message queued to send when the current turn finishes')
} else {
renderSlashOutput('session busy — /interrupt the current turn before sending this command')
}
if (queued !== 'idle') {
renderSlashOutput(
queued === 'queued'
? 'session busy — message queued to send when the current turn finishes'
: 'session busy — /interrupt the current turn before sending this command'
)
return
}
@@ -91,6 +91,7 @@ import {
setWorkspaceCwdOwner,
setYoloActive
} from '@/store/session'
import { clearSessionControl } from '@/store/session-control'
import { isSessionOwnerResolutionError } from '@/store/session-owner-resolution'
import {
beginSessionMutation,
@@ -1001,9 +1002,11 @@ export function useSessionActions({
// dial the owning backend without moving $activeGatewayProfile.
if ($showAllProfiles.get()) {
if (resolvedConnectionId) {
await openGatewayForAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default')
await openGatewayForAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default', {
spawnPriority: 'foreground'
})
} else if (sessionProfile) {
await openGatewayForProfile(normalizeProfileKey(sessionProfile))
await openGatewayForProfile(normalizeProfileKey(sessionProfile), { spawnPriority: 'foreground' })
}
} else if (resolvedConnectionId) {
await ensureGatewayAgent(resolvedConnectionId, ownerRoute?.profile || sessionProfile || 'default')
@@ -2478,6 +2481,7 @@ export function useSessionActions({
if (closingRuntimeId) {
clearQueuedPrompts(closingRuntimeId)
clearSessionControl(closingRuntimeId)
}
// A tiled copy of this session must not outlive it: collapse the pane
@@ -57,11 +57,12 @@ describe('statusbar item visibility', () => {
item('gateway-health', 'Gateway')
])
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal', 'Approvals']) {
for (const label of ['Cron', 'Webhooks', 'Agents', 'Terminal']) {
expect(screen.queryByText(label)).toBeNull()
}
expect(screen.getByText('Gateway')).toBeTruthy()
expect(screen.getByText('Approvals')).toBeTruthy()
})
it('shows an item once the user enables it from the bar context menu', async () => {
+2 -1
View File
@@ -162,7 +162,8 @@ export type CommandDispatchResponse =
| SendCommandDispatchResponse
| PrefillCommandDispatchResponse
export type SidebarNavId = 'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'settings' | 'skills'
export type SidebarNavId =
'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'session-import' | 'settings' | 'skills'
export interface SidebarNavItem {
/** Built-in view id, or a contributed row's namespaced contribution id. */
@@ -449,6 +449,8 @@ interface MarkdownTextSurfaceProps {
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
* model's scratchpad draft must not register artifact versions). */
disableArtifacts?: boolean
/** Foreign history must not load images or mount live transcript directives. */
previewOnly?: boolean
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
@@ -537,7 +539,8 @@ function MarkdownTextSurface({
containerClassName,
containerProps,
defer,
disableArtifacts
disableArtifacts,
previewOnly
}: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
@@ -564,8 +567,9 @@ function MarkdownTextSurface({
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
a: MarkdownLink,
p: (props: ComponentProps<'p'>) =>
previewOnly ? <p {...props} /> : <MarkdownParagraph {...props} streaming={isStreaming} />,
a: previewOnly ? ({ children }: ComponentProps<'a'>) => <span>{children}</span> : MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
@@ -624,13 +628,13 @@ function MarkdownTextSurface({
td: ({ className, ...props }: ComponentProps<'td'>) => (
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
img: previewOnly ? ({ alt }: ComponentProps<'img'>) => <span>{alt}</span> : MarkdownImage,
// ```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)
const artifact = disableArtifacts || previewOnly ? null : detectArtifact(props.language, props.code)
if (artifact) {
return <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
@@ -646,7 +650,7 @@ function MarkdownTextSurface({
)
}
}) as StreamdownTextComponents,
[disableArtifacts, isStreaming]
[disableArtifacts, isStreaming, previewOnly]
)
if (text.length > MAX_MARKDOWN_CHARS) {
@@ -0,0 +1,233 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { $paneStates } from '@/store/panes'
import { group, split, type SplitNode } from '../model'
import { $hiddenTreePanes, $layoutTree, markCollapsePane, setTreeGroupMinimized } from '../store'
import { TreeSplit } from './tree-split'
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const disposers: (() => void)[] = []
beforeAll(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => undefined)
Element.prototype.hasPointerCapture ??= () => false
Element.prototype.setPointerCapture ??= () => undefined
Element.prototype.releasePointerCapture ??= () => undefined
})
beforeEach(() => {
window.localStorage.clear()
$hiddenTreePanes.set(new Set())
$paneStates.set({})
disposers.push(
registry.register({ area: 'panes', data: { placement: 'main' }, id: 'chat', render: () => null, title: 'Chat' }),
registry.register({
area: 'panes',
data: { placement: 'main', width: '100px' },
id: 'cron',
render: () => null,
title: 'Cron'
}),
registry.register({
area: 'panes',
data: { placement: 'main' },
id: 'browser',
render: () => null,
title: 'Browser'
})
)
})
afterEach(() => {
cleanup()
$layoutTree.set(null)
$paneStates.set({})
disposers.splice(0).forEach(dispose => dispose())
})
function rect(width: number): DOMRect {
return {
bottom: 600,
height: 600,
left: 0,
right: width,
toJSON: () => ({}),
top: 0,
width,
x: 0,
y: 0
} as DOMRect
}
function setWidth(element: HTMLElement, width: number) {
Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => rect(width) })
}
function setHeight(element: HTMLElement, height: number) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({ ...rect(1000), bottom: height, height })
})
}
function row(): SplitNode {
const tree = $layoutTree.get()
if (!tree || tree.type !== 'split') {
throw new Error('expected root row split')
}
return tree
}
describe('TreeSplit cascading expansion', () => {
it('grows Browser through Cron into Chat after Cron reaches its minimum', () => {
const tree = split(
'row',
[
group(['chat'], { id: 'chat-zone' }),
group(['cron'], { id: 'cron-zone' }),
group(['browser'], { id: 'browser-zone' })
],
[5, 1, 2],
'root-row'
)
$layoutTree.set(tree)
render(<TreeSplit node={tree} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
const [chat, cron, browser] = [...container.children] as HTMLElement[]
setWidth(container, 800)
setWidth(chat, 500)
setWidth(cron, 100)
setWidth(browser, 200)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
// Browser's 300px requested growth first takes Cron from 100px to its
// 80px floor, then takes the remaining 280px from Chat. The browser gets
// every released pixel instead of stopping at Cron's local floor.
expect($paneStates.get().cron?.widthOverride).toBe(80)
expect(row().weights).toEqual([2.2, 1, 5])
})
it('commits a regular cascade when an unrelated tool rail is already minimized', () => {
markCollapsePane('terminal')
disposers.push(
registry.register({
area: 'panes',
data: { maxWidth: '600px', minWidth: '160px', placement: 'right', width: '200px' },
id: 'browser',
render: () => null,
title: 'Browser'
}),
registry.register({
area: 'panes',
data: { placement: 'bottom' },
id: 'terminal',
render: () => null,
title: 'Terminal'
})
)
const tree = split(
'row',
[
group(['chat'], { id: 'chat-zone' }),
group(['cron'], { id: 'cron-zone' }),
group(['browser'], { id: 'browser-zone' }),
group(['terminal'], { id: 'terminal-zone' })
],
[5, 1, 2, 0.28],
'root-row'
)
$layoutTree.set(tree)
$paneStates.set({ browser: { open: true, widthOverride: 200 } })
setTreeGroupMinimized('terminal-zone', true)
render(<TreeSplit node={row()} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
const [chat, cron, browser, terminal] = [...container.children] as HTMLElement[]
setWidth(container, 828)
setWidth(chat, 500)
setWidth(cron, 100)
setWidth(browser, 200)
setWidth(terminal, 28)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="browser-zone"]')!, 200)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 28)
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
expect($paneStates.get().cron?.widthOverride).toBe(80)
expect($paneStates.get().browser?.widthOverride).toBe(500)
expect(row().weights[0]).toBeCloseTo(2.2)
expect(row().children[3]).toMatchObject({ id: 'terminal-zone', minimized: true })
})
it('folding a tool zone at its floor leaves no drag preview pinned on the flex sibling', () => {
markCollapsePane('terminal')
disposers.push(
registry.register({
area: 'panes',
data: { height: '200px', placement: 'bottom' },
id: 'terminal',
render: () => null,
title: 'Terminal'
})
)
const tree = split(
'column',
[group(['chat'], { id: 'chat-zone' }), group(['terminal'], { id: 'terminal-zone' })],
[1, 1],
'root-column'
)
$layoutTree.set(tree)
render(<TreeSplit node={tree} root />)
const container = document.querySelector<HTMLElement>('[data-tree-split="root-column"]')!
const [chat, terminal] = [...container.children] as HTMLElement[]
setHeight(container, 800)
setHeight(chat, 600)
setHeight(terminal, 200)
setHeight(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 200)
const chatFlex = chat.style.flex
const terminalSash = document.querySelectorAll('[role="separator"]')[0]!
fireEvent.pointerDown(terminalSash, { button: 0, clientY: 600, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientY: 790, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(window, { clientY: 790, pointerId: 1, pointerType: 'mouse' })
// The zone folds to its rail (no sliver persisted) and the chat wrapper —
// which the commit does not re-render — is back on React's own flex, not
// the `0 1 <px>` pin the gesture previewed.
expect(row().children[1]).toMatchObject({ id: 'terminal-zone', minimized: true })
expect($paneStates.get().terminal?.heightOverride).toBeUndefined()
expect(chat.style.flex).toBe(chatFlex)
})
})
@@ -73,6 +73,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
const snapshot = useCallback(() => {
const all = $paneStates.get()
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
if (cache.current.sig !== sig) {
@@ -245,15 +246,165 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
return
}
const a = sideFor(node.children[aIndex], kidA, 'end')
const b = sideFor(node.children[bIndex], kidB, 'start')
const a0px = a.fixed ? a.size : sizeOf(kidA)
const b0px = b.fixed ? b.size : sizeOf(kidB)
const lo = Math.max(a.min - a0px, b0px - b.max)
const hi = Math.min(a.max - a0px, b0px - b.min)
const tracks = node.children.map((child, index) => {
const element = container.children[index] as HTMLElement | undefined
if (!element) {
return null
}
const side = sideFor(child, element, index <= aIndex ? 'end' : 'start')
return {
...side,
element,
index,
initial: side.fixed ? side.size : sizeOf(element),
// A minimized rail is its 28px strip: it neither donates nor takes,
// and its remembered weight must survive the gesture so restoring
// it brings back the size it had before it was folded.
minimized: child.type === 'group' && Boolean(child.minimized),
visible: !isCollapsed(child)
}
})
if (tracks.some(track => !track)) {
return
}
const sashTracks = tracks as Array<NonNullable<(typeof tracks)[number]>>
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
// The seam is ONE boundary of a whole run: the side the pointer moves
// toward gives up space, its seam partner first. When the partner hits
// its floor the drag keeps going and takes from the partner's next
// visible sibling, and so on — a row behaves like one workspace instead
// of a set of unrelated boxes. Plans are absolute (from the sizes at
// pointerdown), so every frame is a pure function of the pointer.
const planFor = (requestedShift: number, allowCascade: boolean) => {
const toward = requestedShift >= 0 ? 1 : -1
const targetIndex = toward > 0 ? aIndex : bIndex
// The partner is the nearest VISIBLE sibling (`aIndex`/`bIndex`), not
// `target ± 1` — a hidden zone parked between them (the closed preview
// pane) is display:none and must neither donate nor block.
const partnerIndex = toward > 0 ? bIndex : aIndex
const target = sashTracks[targetIndex]
const next = sashTracks.map(track => track.initial)
let remaining = Math.min(Math.abs(requestedShift), Math.max(0, target.max - target.initial))
let transferred = 0
let cascaded = false
// A tool rail is locally resizable at its own seam, but must not make
// every unrelated seam in the row local-only. It also remains the
// terminal donor when reached only after another regular track.
const cascade = allowCascade && !target.collapseId
for (let donorIndex = partnerIndex; donorIndex >= 0 && donorIndex < sashTracks.length; donorIndex += toward) {
const donor = sashTracks[donorIndex]
if (!donor.visible) {
continue
}
if (donor.collapseId && donorIndex !== partnerIndex) {
break
}
const take = Math.min(remaining, Math.max(0, donor.initial - donor.min))
next[donorIndex] -= take
transferred += take
remaining -= take
if (take > 0 && donorIndex !== partnerIndex) {
cascaded = true
}
if (remaining === 0 || !cascade) {
break
}
}
next[targetIndex] += transferred
return { cascaded, moved: Math.sign(requestedShift) * transferred, sizes: next }
}
// One store commit on release: fixed zones get their px override
// (sidebar semantics), the flex run gets weights. Clamped px → weights
// so persisted weights match what's on screen.
const commitPlan = (plan: ReturnType<typeof planFor>) => {
const weights = [...node.weights]
let weightsChanged = false
sashTracks.forEach((track, index) => {
if (!track.visible || track.minimized) {
return
}
const px = Math.round(plan.sizes[index])
if (track.fixed) {
if (px !== Math.round(track.initial)) {
track.paneIds.forEach(id => setOverride(id, px))
}
} else {
weights[index] = Math.max(0.01, plan.sizes[index] / pxPerWeight)
weightsChanged = true
}
})
if (weightsChanged) {
setTreeSplitWeights(node.id, weights)
}
}
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
const restoreStyles = () => {
sashTracks.forEach((track, index) => {
const style = styleSnapshots[index]
if (style === null) {
track.element.removeAttribute('style')
} else {
track.element.setAttribute('style', style)
}
})
}
// 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 run 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 track 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.
// - every visible flex track is pinned to `0 1 <px>`. The plan keeps
// the run's total px constant, so no leftover gap can open.
// - hidden (display:none) and minimized tracks are left untouched.
// - cleanup: release restores the captured style attributes, then a
// real drag commits the store once and React re-renders from it.
const previewPlan = (plan: ReturnType<typeof planFor>) => {
sashTracks.forEach((track, index) => {
if (!track.visible || track.minimized) {
return
}
const px = plan.sizes[index]
if (track.fixed) {
track.element.style.flexBasis = `${px}px`
} else {
track.element.style.flex = `0 1 ${px}px`
}
})
}
try {
handle.setPointerCapture?.(pointerId)
} catch {
@@ -272,70 +423,26 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
// 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)))
}
if (b.fixed) {
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
}
if (!a.fixed && !b.fixed) {
const weights = [...node.weights]
// Clamped px → weights so persisted weights match what's on screen.
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
setTreeSplitWeights(node.id, weights)
}
}
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 resize = rafCoalesce(previewPlan)
// A return drag stays local only after this gesture has actually crossed
// a second donor. A one-pixel opposite wobble must not poison the real
// forward movement that follows.
let cascadeDirection: -1 | 1 | null = null
let lastPlan: null | ReturnType<typeof planFor> = null
let done = false
const onMove = (ev: PointerEvent) => {
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
resize.push(lastShift)
const shift = (horizontal ? ev.clientX : ev.clientY) - start
const direction = Math.sign(shift) as -1 | 0 | 1
const allowCascade = cascadeDirection === null || direction === cascadeDirection
lastPlan = planFor(shift, allowCascade)
if (cascadeDirection === null && direction !== 0 && lastPlan.cascaded) {
cascadeDirection = direction
}
resize.push(lastPlan)
}
// Ends through several racing paths (pointerup, pointercancel, window
@@ -349,35 +456,28 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
done = true
resize.finish()
if (lastShift !== null) {
// Put every wrapper's inline style back exactly as React last wrote
// it BEFORE the store commit. React only rewrites a wrapper whose
// style prop changed; a preview pinned on a track the commit leaves
// alone (the flex run beside a zone that folded to its rail) would
// otherwise survive as a stale `flex: 0 1 <px>` and stop it growing.
// A no-movement click has no commit, so this is also its whole cleanup.
restoreStyles()
if (lastPlan && lastPlan.moved !== 0) {
// Dragged a tool panel down to its collapsed header? Fold the zone
// to its rail instead of persisting a sliver — and DON'T write the
// sliver size, so restoring brings back the size it had before.
const collapsedSide =
(a.collapseId && a0px + lastShift <= a.floor && a.collapseId) ||
(b.collapseId && b0px - lastShift <= b.floor && b.collapseId) ||
null
// Only a track THIS gesture took to its floor counts: an unrelated
// rail already resting there must not cancel the commit.
const collapsedSide = sashTracks.find(
(track, index) => track.collapseId && track.initial > track.floor && lastPlan!.sizes[index] <= track.floor
)?.collapseId
if (collapsedSide) {
setTreeGroupMinimized(collapsedSide, true)
} else {
// 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)
commitPlan(lastPlan)
}
}
+5 -1
View File
@@ -20,12 +20,16 @@ declare global {
// Resolve a backend connection. Omit `profile` (or pass the primary) for
// the window's backend; pass a named profile to lazily spawn/reuse that
// profile's backend from the pool.
getConnection: (profile?: string | null) => Promise<HermesConnection>
getConnection: (
profile?: string | null,
opts?: { priority?: 'foreground' | 'background' }
) => Promise<HermesConnection>
// Registry-scoped backend resolution: dial (connectionId, profile). An
// empty/local connectionId delegates to the legacy getConnection path.
getConnectionFor?: (payload: {
connectionId?: null | string
profile?: null | string
priority?: 'foreground' | 'background'
}) => Promise<HermesConnection>
// Registry-scoped fresh WS URL (same result contract as getGatewayWsUrl).
getGatewayWsUrlFor?: (payload: {
+128 -1
View File
@@ -1,6 +1,39 @@
import { defineLocale } from './define-locale'
export const ar = defineLocale({
sessionImport: {
title: 'المتابعة من تطبيق آخر',
subtitle: 'انقل محادثة إلى Hermes وتابع من حيث توقفت.',
action: 'استيراد جلسة',
readingFrom: 'القراءة من',
connectedComputer: 'الكمبيوتر المتصل',
destination: 'الاستيراد إلى',
all: 'الكل',
search: 'البحث في الجلسات المحملة',
scanning: 'جارٍ البحث عن المحادثات',
scanError: 'تعذر العثور على الجلسات',
scanHelp: 'تحقق من اتصال الخادم ثم أعد المحاولة. قد تحتاج الخوادم القديمة إلى تحديث.',
empty: 'لا توجد محادثات',
emptyHelp: 'ستظهر هنا جلسات Claude Code وCodex الموجودة على هذا الخادم.',
noMatches: 'لا توجد محادثات مطابقة',
searchHelp: 'جرّب عنوانًا أو مجلدًا آخر، أو حمّل المزيد من الجلسات.',
skipped: 'تم تجاوز بعض السجلات الفارغة أو غير المقروءة أو الكبيرة جدًا.',
more: 'تحميل المزيد من الجلسات',
messages: 'رسائل',
choose: 'محادثة تستحق المتابعة',
chooseHelp: 'اختر جلسة لقراءة سجلها قبل نقلها إلى Hermes.',
previewLoading: 'جارٍ فتح المعاينة',
previewError: 'المعاينة غير متاحة',
previewHelp: 'ربما تم نقل الملف الأصلي أو تغييره. حدّث القائمة وحاول مرة أخرى.',
previewLimit: 'تم اختصار المعاينة لتسهيل القراءة. يتم استيراد المحادثة كاملة.',
you: 'أنت',
snapshot: 'هذه المحادثة موجودة بالفعل في Hermes. افتح نسختك الحالية للمتابعة.',
copyNotice: 'ينسخ نص المحادثة دون تغيير الملفات الأصلية. لا يشمل مخرجات الأدوات أو الاستدلال.',
importing: 'جارٍ الاستيراد…',
open: 'فتح في Hermes',
continue: 'المتابعة في Hermes',
importError: 'تعذر استيراد هذه المحادثة.'
},
sendDiagnostics: {
title: 'إرسال التشخيصات إلى Nous',
privacyNotice:
@@ -1698,7 +1731,8 @@ export const ar = defineLocale({
chat: 'المحادثة',
settings: 'الإعدادات',
cron: 'المهام المجدولة',
agents: 'الوكلاء'
agents: 'الوكلاء',
'session-import': 'استيراد جلسة'
},
searchAria: 'البحث في الجلسات',
searchPlaceholder: 'البحث في الجلسات...',
@@ -1951,12 +1985,105 @@ export const ar = defineLocale({
statusStack: {
agents: 'الوكلاء',
background: count => `${count} في الخلفية`,
goalActive: 'الهدف نشط',
goalBlocked: 'الهدف متعذر',
goalDone: 'اكتمل الهدف',
goalPaused: 'الهدف متوقف مؤقتًا',
goalWaiting: 'الهدف قيد الانتظار',
subagents: count => `${count} ${count === 1 ? 'وكيل فرعي' : 'وكيل فرعي'}`,
todos: (done, total) => `المهام ${done}/${total}`,
running: 'قيد التشغيل',
stop: 'إيقاف',
dismiss: 'تجاهل',
exit: code => `خروج ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `الجولة ${turn}/${maxTurns}`,
goalDoneTurns: turns => `${turns} جولات`,
goalTurn: turn => `الجولة ${turn}`,
goalActions: 'إجراءات الهدف',
viewDetails: 'عرض التفاصيل',
addCriterion: 'إضافة معيار',
addCriterionDialogTitle: 'إضافة معيار',
addCriterionPlaceholder: 'أدخل نص المعيار...',
criterionLabel: 'المعيار',
pauseGoal: 'إيقاف الهدف مؤقتاً',
resumeGoal: 'استئناف الهدف',
resumeNow: 'استئناف الآن',
clearGoal: 'مسح الهدف',
clearGoalConfirmTitle: 'مسح الهدف؟',
clearGoalConfirmBody: 'هل أنت متأكد من مسح الهدف النشط؟ لا يمكن التراجع عن هذا الإجراء.',
copyCriterion: index => `نسخ المعيار ${index}`,
removeCriterion: index => `إزالة المعيار ${index}`,
removeCriterionConfirmTitle: index => `إزالة المعيار ${index}؟`,
removeCriterionConfirmBody: index => `هل أنت متأكد من إزالة المعيار ${index}؟`,
clearCriteria: 'مسح جميع المعايير',
clearCriteriaConfirmTitle: 'مسح جميع المعايير؟',
clearCriteriaConfirmBody: 'هل أنت متأكد من رغبتك في إزالة جميع المعايير من هذا الهدف؟',
criteriaHeader: count => `المعايير · ${count}`,
noCriteria: 'لا توجد معايير',
goalDetailsTitle: 'تفاصيل الهدف',
objectiveLabel: 'الهدف',
contractOutcome: 'النتيجة',
contractVerification: 'التحقق',
contractConstraints: 'القيود',
contractBoundaries: 'الحدود',
contractStopWhen: 'التوقف عند',
waitBarrierTitle: 'شرط الانتظار',
waitUntil: target => `في الانتظار حتى ${target}`,
waitSession: target => `في انتظار الجلسة ${target}`,
waitPid: pid => `في انتظار العملية ${pid}`,
qualityGatesTitle: 'بوابات الجودة',
gateCommand: 'الأمر',
gateAttempts: (attempts, max) => `${attempts}/${max} محاولات`,
gateTimeout: seconds => `مهلة ${seconds} ثانية`,
gateLastExit: code => (code === null ? 'قيد الانتظار' : `رمز الخروج: ${code}`),
loopActive: 'التكرار نشط',
loopPaused: 'التكرار متوقف مؤقتاً',
loopDeferred: 'التكرار مؤجل',
loopFinished: 'التكرار منتهي',
loopRuns: runs => `${runs} مرات تشغيل`,
loopRunCount: (current, total) => `تشغيل ${current}/${total}`,
loopNext: time => `التالي ${time}`,
loopEverySeconds: seconds => `كل ${seconds} ث`,
loopEveryMinutes: minutes => `كل ${minutes} د`,
loopEveryHours: hours => `كل ${hours} س`,
loopSelfPaced: 'ذاتي الإيقاع',
loopActions: 'إجراءات التكرار',
pauseLoop: 'إيقاف التكرار مؤقتاً',
resumeLoop: 'استئناف التكرار',
stopLoop: 'إيقاف التكرار',
stopLoopConfirmTitle: 'إيقاف التكرار؟',
stopLoopConfirmBody: 'هل أنت متأكد من رغبتك في إيقاف هذا التكرار؟',
dismissLoop: 'تجاهل التكرار',
loopPromptLabel: 'الموجه',
loopCadenceLabel: 'الإيقاع',
loopUntilLabel: 'شرط الانتهاء',
loopDeferredNotice: 'الهدف النشط يتحكم بالجلسة حالياً.',
loopAwaitingResponse: 'في انتظار الاستجابة',
heartbeatActive: 'النبض نشط',
heartbeatPaused: 'النبض متوقف مؤقتاً',
heartbeatEveryMinutes: minutes => `كل ${minutes} د`,
heartbeatEveryHours: hours => `كل ${hours} س`,
heartbeatEverySeconds: seconds => `كل ${seconds} ث`,
heartbeatNext: time => `التالي ${time}`,
heartbeatActions: 'إجراءات النبض',
pauseHeartbeat: 'إيقاف النبض مؤقتاً',
resumeHeartbeat: 'استئناف النبض',
clearHeartbeat: 'مسح النبض',
clearHeartbeatConfirmTitle: 'مسح النبض؟',
clearHeartbeatConfirmBody: 'هل أنت متأكد من رغبتك في مسح هذا النبض؟',
heartbeatFiredCount: count => `انطلق ${count} مرة`,
actionFailed: msg => `فشل الإجراء: ${msg}`,
actionSucceeded: 'تم الإجراء بنجاح',
copySuccess: 'تم نسخ المعيار إلى الحافظة',
copyFailure: 'فشل نسخ المعيار إلى الحافظة',
continuationFailed: 'فشل إرسال متابعة الهدف',
continuationQueued: 'تم استئناف الهدف — المتابعة في قائمة الانتظار حتى انتهاء الدور الحالي',
continuationBusy: 'تم استئناف الهدف — الجلسة مشغولة، نفّذ /interrupt للدور الحالي للمتابعة',
controlUnavailable: msg => `عناصر تحكم الجلسة غير متاحة: ${msg}`,
dismissError: 'تجاهل الخطأ',
add: 'إضافة'
},
coding: {
title: 'شجرة العمل',
noBranch: 'لا يوجد فرع',
+126 -1
View File
@@ -3,6 +3,40 @@ import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
sessionImport: {
title: 'Continue from another app',
subtitle: 'Bring a conversation into Hermes and pick up where you left off.',
action: 'Import session',
readingFrom: 'Reading from',
connectedComputer: 'the connected computer',
destination: 'Import into',
all: 'All',
search: 'Search loaded sessions',
scanning: 'Finding conversations',
scanError: 'Could not find sessions',
scanHelp: 'Check your backend connection, then try again. Older backends may need an update.',
empty: 'No conversations found',
emptyHelp: 'Claude Code and Codex sessions on this backend will appear here.',
noMatches: 'No matching conversations',
searchHelp: 'Try another title or folder, or load more sessions.',
skipped: 'Some logs were empty, unreadable, or too large to preview.',
more: 'Load more sessions',
messages: 'messages',
choose: 'A conversation worth continuing',
chooseHelp: 'Choose a session to read its history before bringing it into Hermes.',
previewLoading: 'Opening preview',
previewError: 'Preview unavailable',
previewHelp: 'The source may have moved or changed. Refresh the list and try again.',
previewLimit: 'Preview shortened for readability. The complete conversation is imported.',
you: 'You',
snapshot: 'This conversation is already in Hermes. Open your existing copy to continue.',
copyNotice:
'Copies conversation text. Source files stay unchanged. Tool output and reasoning are not carried over.',
importing: 'Importing…',
open: 'Open in Hermes',
continue: 'Continue in Hermes',
importError: 'Could not import this conversation.'
},
common: {
apply: 'Apply',
back: 'Back',
@@ -2323,7 +2357,8 @@ export const en: Translations = {
skills: 'Capabilities',
messaging: 'Messaging',
artifacts: 'Artifacts',
cron: 'Scheduled jobs'
cron: 'Scheduled jobs',
'session-import': 'Import session'
},
searchAria: 'Search sessions',
searchPlaceholder: 'Search sessions…',
@@ -2665,6 +2700,7 @@ export const en: Translations = {
agents: 'Agents',
background: count => `${count} Background`,
goalActive: 'Goal active',
goalBlocked: 'Goal blocked',
goalDone: 'Goal done',
goalPaused: 'Goal paused',
goalWaiting: 'Goal waiting',
@@ -2674,6 +2710,95 @@ export const en: Translations = {
stop: 'Stop',
dismiss: 'Dismiss',
exit: code => `exit ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `Turn ${turn}/${maxTurns}`,
goalDoneTurns: turns => `${turns} turn${turns === 1 ? '' : 's'}`,
goalTurn: turn => `Turn ${turn}`,
goalActions: 'Goal actions',
viewDetails: 'View details',
addCriterion: 'Add criterion',
addCriterionDialogTitle: 'Add criterion',
addCriterionPlaceholder: 'Enter criterion text...',
criterionLabel: 'Criterion',
pauseGoal: 'Pause goal',
resumeGoal: 'Resume goal',
resumeNow: 'Resume now',
clearGoal: 'Clear goal',
clearGoalConfirmTitle: 'Clear goal?',
clearGoalConfirmBody: 'Are you sure you want to clear the active goal? This cannot be undone.',
copyCriterion: index => `Copy criterion ${index}`,
removeCriterion: index => `Remove criterion ${index}`,
removeCriterionConfirmTitle: index => `Remove criterion ${index}?`,
removeCriterionConfirmBody: index => `Are you sure you want to remove criterion ${index}?`,
clearCriteria: 'Clear all criteria',
clearCriteriaConfirmTitle: 'Clear all criteria?',
clearCriteriaConfirmBody: 'Are you sure you want to remove all criteria from this goal?',
criteriaHeader: count => `Criteria · ${count}`,
noCriteria: 'No criteria',
goalDetailsTitle: 'Goal details',
objectiveLabel: 'Objective',
contractOutcome: 'Outcome',
contractVerification: 'Verification',
contractConstraints: 'Constraints',
contractBoundaries: 'Boundaries',
contractStopWhen: 'Stop when',
waitBarrierTitle: 'Wait condition',
waitUntil: target => `Waiting until ${target}`,
waitSession: target => `Waiting on session ${target}`,
waitPid: pid => `Waiting on process ${pid}`,
qualityGatesTitle: 'Quality gates',
gateCommand: 'Command',
gateAttempts: (attempts, max) => `${attempts}/${max} attempts`,
gateTimeout: seconds => `${seconds}s timeout`,
gateLastExit: code => (code === null ? 'Pending' : `Exit code: ${code}`),
loopActive: 'Loop active',
loopPaused: 'Loop paused',
loopDeferred: 'Loop deferred',
loopFinished: 'Loop finished',
loopRuns: runs => `${runs} run${runs === 1 ? '' : 's'}`,
loopRunCount: (current, total) => `Run ${current}/${total}`,
loopNext: time => `next ${time}`,
loopEverySeconds: seconds => `every ${seconds}s`,
loopEveryMinutes: minutes => `every ${minutes}m`,
loopEveryHours: hours => `every ${hours}h`,
loopSelfPaced: 'self-paced',
loopActions: 'Loop actions',
pauseLoop: 'Pause loop',
resumeLoop: 'Resume loop',
stopLoop: 'Stop loop',
stopLoopConfirmTitle: 'Stop loop?',
stopLoopConfirmBody: 'Are you sure you want to stop this loop?',
dismissLoop: 'Dismiss loop',
loopPromptLabel: 'Prompt',
loopCadenceLabel: 'Cadence',
loopUntilLabel: 'Until condition',
loopDeferredNotice: 'An active goal currently controls the session.',
loopAwaitingResponse: 'Awaiting response',
heartbeatActive: 'Heartbeat active',
heartbeatPaused: 'Heartbeat paused',
heartbeatEveryMinutes: minutes => `every ${minutes}m`,
heartbeatEveryHours: hours => `every ${hours}h`,
heartbeatEverySeconds: seconds => `every ${seconds}s`,
heartbeatNext: time => `next ${time}`,
heartbeatDueWaitingForIdle: 'due — waiting for idle',
heartbeatActions: 'Heartbeat actions',
pauseHeartbeat: 'Pause heartbeat',
resumeHeartbeat: 'Resume heartbeat',
clearHeartbeat: 'Clear heartbeat',
clearHeartbeatConfirmTitle: 'Clear heartbeat?',
clearHeartbeatConfirmBody: 'Are you sure you want to clear this heartbeat?',
heartbeatFiredCount: count => `Fired ${count} time${count === 1 ? '' : 's'}`,
actionFailed: msg => `Action failed: ${msg}`,
actionSucceeded: 'Action succeeded',
copySuccess: 'Criterion copied to clipboard',
copyFailure: 'Failed to copy criterion to clipboard',
continuationFailed: 'Failed to submit goal continuation',
continuationQueued: 'Goal resumed — continuation queued until the current turn finishes',
continuationBusy: 'Goal resumed — session busy, /interrupt the current turn to continue',
controlUnavailable: msg => `Session controls unavailable: ${msg}`,
dismissError: 'Dismiss error',
add: 'Add'
},
coding: {
title: 'Working tree',
noBranch: 'No branch',
+125 -1
View File
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale } from './define-locale'
export const ja = defineLocale({
sessionImport: {
title: '別のアプリから続ける',
subtitle: '会話をHermesに取り込み、続きを始めましょう。',
action: 'セッションを取り込む',
readingFrom: '読み込み元',
connectedComputer: '接続先のコンピューター',
destination: '取り込み先',
all: 'すべて',
search: '読み込み済みのセッションを検索',
scanning: '会話を検索中',
scanError: 'セッションを取得できません',
scanHelp: 'バックエンドの接続を確認して再試行してください。古いバックエンドは更新が必要な場合があります。',
empty: '会話が見つかりません',
emptyHelp: 'このバックエンドのClaude CodeとCodexのセッションがここに表示されます。',
noMatches: '一致する会話がありません',
searchHelp: '別のタイトルやフォルダーを検索するか、セッションを追加で読み込んでください。',
skipped: '空、読み込み不可、または大きすぎるログをスキップしました。',
more: 'さらに読み込む',
messages: 'メッセージ',
choose: '会話の続きを始めましょう',
chooseHelp: 'セッションを選び、取り込む前に履歴を確認できます。',
previewLoading: 'プレビューを開いています',
previewError: 'プレビューできません',
previewHelp: '元のファイルが移動または変更された可能性があります。一覧を更新してください。',
previewLimit: '読みやすいようにプレビューを省略しています。取り込み時は会話全体をコピーします。',
you: 'あなた',
snapshot: 'この会話は取り込み済みです。既存のコピーを開いて続けられます。',
copyNotice: '会話のテキストをコピーします。元のファイルは変更されません。ツール出力と推論は含まれません。',
importing: '取り込み中…',
open: 'Hermesで開く',
continue: 'Hermesで続ける',
importError: '会話を取り込めませんでした。'
},
common: {
apply: '適用',
back: '戻る',
@@ -1998,7 +2031,8 @@ export const ja = defineLocale({
skills: 'スキルとツール',
messaging: 'メッセージング',
artifacts: 'アーティファクト',
cron: 'スケジュール済みジョブ'
cron: 'スケジュール済みジョブ',
'session-import': 'セッションを取り込む'
},
searchAria: 'セッションを検索',
searchPlaceholder: 'セッションを検索…',
@@ -2291,6 +2325,7 @@ export const ja = defineLocale({
agents: 'エージェント',
background: count => `バックグラウンド ${count} 件`,
goalActive: '目標進行中',
goalBlocked: '目標がブロックされています',
goalDone: '目標達成',
goalPaused: '目標一時停止中',
goalWaiting: '目標待機中',
@@ -2300,6 +2335,95 @@ export const ja = defineLocale({
stop: '停止',
dismiss: '閉じる',
exit: code => `終了コード ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `ターン ${turn}/${maxTurns}`,
goalDoneTurns: turns => `${turns} ターン`,
goalTurn: turn => `ターン ${turn}`,
goalActions: 'ゴールのアクション',
viewDetails: '詳細を表示',
addCriterion: '基準を追加',
addCriterionDialogTitle: '基準の追加',
addCriterionPlaceholder: '基準を入力...',
criterionLabel: '基準',
pauseGoal: 'ゴールを一時停止',
resumeGoal: 'ゴールを再開',
resumeNow: '今すぐ再開',
clearGoal: 'ゴールをクリア',
clearGoalConfirmTitle: 'ゴールをクリアしますか?',
clearGoalConfirmBody: 'アクティブなゴールをクリアしてもよろしいですか?この操作は取り消せません。',
copyCriterion: index => `基準 ${index} をコピー`,
removeCriterion: index => `基準 ${index} を削除`,
removeCriterionConfirmTitle: index => `基準 ${index} を削除しますか?`,
removeCriterionConfirmBody: index => `基準 ${index} を削除してもよろしいですか?`,
clearCriteria: 'すべての基準をクリア',
clearCriteriaConfirmTitle: 'すべての基準をクリアしますか?',
clearCriteriaConfirmBody: 'このゴールからすべての基準を削除してもよろしいですか?',
criteriaHeader: count => `基準 · ${count}`,
noCriteria: '基準なし',
goalDetailsTitle: 'ゴールの詳細',
objectiveLabel: '目標',
contractOutcome: '成果',
contractVerification: '検証',
contractConstraints: '制約',
contractBoundaries: '境界',
contractStopWhen: '終了条件',
waitBarrierTitle: '待機条件',
waitUntil: target => `${target} まで待機`,
waitSession: target => `セッション ${target} を待機中`,
waitPid: pid => `プロセス ${pid} を待機中`,
qualityGatesTitle: '品質ゲート',
gateCommand: 'コマンド',
gateAttempts: (attempts, max) => `試行 ${attempts}/${max}`,
gateTimeout: seconds => `タイムアウト ${seconds}秒`,
gateLastExit: code => (code === null ? '保留中' : `終了コード: ${code}`),
loopActive: 'ループ実行中',
loopPaused: 'ループ一時停止',
loopDeferred: 'ループ保留中',
loopFinished: 'ループ終了',
loopRuns: runs => `${runs} 回実行`,
loopRunCount: (current, total) => `実行 ${current}/${total}`,
loopNext: time => `次回 ${time}`,
loopEverySeconds: seconds => `${seconds}秒ごと`,
loopEveryMinutes: minutes => `${minutes}分ごと`,
loopEveryHours: hours => `${hours}時間ごと`,
loopSelfPaced: 'セルフペース',
loopActions: 'ループのアクション',
pauseLoop: 'ループを一時停止',
resumeLoop: 'ループを再開',
stopLoop: 'ループを停止',
stopLoopConfirmTitle: 'ループを停止しますか?',
stopLoopConfirmBody: 'このループを停止してもよろしいですか?',
dismissLoop: 'ループを閉じる',
loopPromptLabel: 'プロンプト',
loopCadenceLabel: 'ペース',
loopUntilLabel: '終了条件',
loopDeferredNotice: '現在アクティブなゴールがセッションを制御しています。',
loopAwaitingResponse: '応答待ち',
heartbeatActive: 'ハートビート実行中',
heartbeatPaused: 'ハートビート一時停止',
heartbeatEveryMinutes: minutes => `${minutes}分ごと`,
heartbeatEveryHours: hours => `${hours}時間ごと`,
heartbeatEverySeconds: seconds => `${seconds}秒ごと`,
heartbeatNext: time => `次回 ${time}`,
heartbeatDueWaitingForIdle: '期限到来 — アイドル待ち',
heartbeatActions: 'ハートビートのアクション',
pauseHeartbeat: 'ハートビートを一時停止',
resumeHeartbeat: 'ハートビートを再開',
clearHeartbeat: 'ハートビートをクリア',
clearHeartbeatConfirmTitle: 'ハートビートをクリアしますか?',
clearHeartbeatConfirmBody: 'このハートビートをクリアしてもよろしいですか?',
heartbeatFiredCount: count => `${count} 回発火`,
actionFailed: msg => `アクションに失敗しました: ${msg}`,
actionSucceeded: 'アクションが完了しました',
copySuccess: '基準をクリップボードにコピーしました',
copyFailure: '基準のコピーに失敗しました',
continuationFailed: 'ゴールの継続送信に失敗しました',
continuationQueued: 'ゴールを再開しました — 継続は現在のターン終了後に送信されます',
continuationBusy: 'ゴールを再開しました — セッションが実行中です。続行するには /interrupt してください',
controlUnavailable: msg => `セッション制御を利用できません: ${msg}`,
dismissError: 'エラーを閉じる',
add: '追加'
},
coding: {
title: 'ワークツリー',
noBranch: 'ブランチなし',
+126 -1
View File
@@ -24,6 +24,40 @@ const RU_NOUN = (count: number | string, one: string, few: string, many: string)
}
export const ru = defineLocale({
sessionImport: {
title: 'Продолжить из другого приложения',
subtitle: 'Перенесите разговор в Hermes и продолжите с того места, где остановились.',
action: 'Импортировать сессию',
readingFrom: 'Читаем с',
connectedComputer: 'подключённого компьютера',
destination: 'Импорт в',
all: 'Все',
search: 'Поиск по загруженным сессиям',
scanning: 'Поиск разговоров',
scanError: 'Не удалось найти сессии',
scanHelp: 'Проверьте подключение к серверу и повторите попытку. Старому серверу может требоваться обновление.',
empty: 'Разговоров пока нет',
emptyHelp: 'Здесь появятся сессии Claude Code и Codex с этого сервера.',
noMatches: 'Совпадений нет',
searchHelp: 'Попробуйте другой заголовок или папку либо загрузите ещё сессии.',
skipped: 'Некоторые журналы пусты, недоступны или слишком велики для просмотра.',
more: 'Загрузить ещё сессии',
messages: 'сообщений',
choose: 'Разговор, который стоит продолжить',
chooseHelp: 'Выберите сессию, чтобы прочитать историю перед импортом в Hermes.',
previewLoading: 'Открываем просмотр',
previewError: 'Просмотр недоступен',
previewHelp: 'Исходный файл мог переместиться или измениться. Обновите список и повторите попытку.',
previewLimit: 'Просмотр сокращён для удобства чтения. Импортируется весь разговор.',
you: 'Вы',
snapshot: 'Этот разговор уже есть в Hermes. Откройте существующую копию, чтобы продолжить.',
copyNotice:
'Копируется текст разговора. Исходные файлы не меняются. Вывод инструментов и рассуждения не переносятся.',
importing: 'Импорт…',
open: 'Открыть в Hermes',
continue: 'Продолжить в Hermes',
importError: 'Не удалось импортировать разговор.'
},
common: {
apply: 'Применить',
back: 'Назад',
@@ -2359,7 +2393,8 @@ export const ru = defineLocale({
skills: 'Возможности',
messaging: 'Сообщения',
artifacts: 'Артефакты',
cron: 'Запланированные задачи'
cron: 'Запланированные задачи',
'session-import': 'Импортировать сессию'
},
searchAria: 'Поиск сеансов',
searchPlaceholder: 'Поиск сеансов…',
@@ -2701,6 +2736,7 @@ export const ru = defineLocale({
agents: 'Агенты',
background: count => `${count} ${RU_NOUN(count, 'фоновая задача', 'фоновые задачи', 'фоновых задач')}`,
goalActive: 'Цель активна',
goalBlocked: 'Цель заблокирована',
goalDone: 'Цель выполнена',
goalPaused: 'Цель на паузе',
goalWaiting: 'Цель ожидает',
@@ -2710,6 +2746,95 @@ export const ru = defineLocale({
stop: 'Стоп',
dismiss: 'Скрыть',
exit: code => `exit ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `Ход ${turn}/${maxTurns}`,
goalDoneTurns: turns => `${turns} ходов`,
goalTurn: turn => `Ход ${turn}`,
goalActions: 'Действия с целью',
viewDetails: 'Подробнее',
addCriterion: 'Добавить критерий',
addCriterionDialogTitle: 'Добавить критерий',
addCriterionPlaceholder: 'Введите текст критерия...',
criterionLabel: 'Критерий',
pauseGoal: 'Приостановить цель',
resumeGoal: 'Возобновить цель',
resumeNow: 'Возобновить сейчас',
clearGoal: 'Очистить цель',
clearGoalConfirmTitle: 'Очистить цель?',
clearGoalConfirmBody: 'Вы уверены, что хотите очистить активную цель? Это действие необратимо.',
copyCriterion: index => `Скопировать критерий ${index}`,
removeCriterion: index => `Удалить критерий ${index}`,
removeCriterionConfirmTitle: index => `Удалить критерий ${index}?`,
removeCriterionConfirmBody: index => `Вы уверены, что хотите удалить критерий ${index}?`,
clearCriteria: 'Очистить все критерии',
clearCriteriaConfirmTitle: 'Очистить все критерии?',
clearCriteriaConfirmBody: 'Вы уверены, что хотите удалить все критерии этой цели?',
criteriaHeader: count => `Критерии · ${count}`,
noCriteria: 'Нет критериев',
goalDetailsTitle: 'Детали цели',
objectiveLabel: 'Задача',
contractOutcome: 'Результат',
contractVerification: 'Проверка',
contractConstraints: 'Ограничения',
contractBoundaries: 'Границы',
contractStopWhen: 'Условие остановки',
waitBarrierTitle: 'Условие ожидания',
waitUntil: target => `Ожидание до ${target}`,
waitSession: target => `Ожидание сессии ${target}`,
waitPid: pid => `Ожидание процесса ${pid}`,
qualityGatesTitle: 'Контроли качества',
gateCommand: 'Команда',
gateAttempts: (attempts, max) => `${attempts}/${max} попыток`,
gateTimeout: seconds => `таймаут ${seconds}с`,
gateLastExit: code => (code === null ? 'В ожидании' : `Код возврата: ${code}`),
loopActive: 'Цикл активен',
loopPaused: 'Цикл приостановлен',
loopDeferred: 'Цикл отложен',
loopFinished: 'Цикл завершен',
loopRuns: runs => `${runs} запусков`,
loopRunCount: (current, total) => `Запуск ${current}/${total}`,
loopNext: time => `следующий ${time}`,
loopEverySeconds: seconds => `каждые ${seconds}с`,
loopEveryMinutes: minutes => `каждые ${minutes}м`,
loopEveryHours: hours => `каждые ${hours}ч`,
loopSelfPaced: 'автономный',
loopActions: 'Действия цикла',
pauseLoop: 'Приостановить цикл',
resumeLoop: 'Возобновить цикл',
stopLoop: 'Остановить цикл',
stopLoopConfirmTitle: 'Остановить цикл?',
stopLoopConfirmBody: 'Вы уверены, что хотите остановить этот цикл?',
dismissLoop: 'Закрыть цикл',
loopPromptLabel: 'Промпт',
loopCadenceLabel: 'Интервал',
loopUntilLabel: 'Условие окончания',
loopDeferredNotice: 'Активная цель в данный момент управляет сессией.',
loopAwaitingResponse: 'Ожидание ответа',
heartbeatActive: 'Пульс активен',
heartbeatPaused: 'Пульс приостановлен',
heartbeatEveryMinutes: minutes => `каждые ${minutes}м`,
heartbeatEveryHours: hours => `каждые ${hours}ч`,
heartbeatEverySeconds: seconds => `каждые ${seconds}с`,
heartbeatNext: time => `следующий ${time}`,
heartbeatDueWaitingForIdle: 'пора — ожидание простоя',
heartbeatActions: 'Действия пульса',
pauseHeartbeat: 'Приостановить пульс',
resumeHeartbeat: 'Возобновить пульс',
clearHeartbeat: 'Очистить пульс',
clearHeartbeatConfirmTitle: 'Очистить пульс?',
clearHeartbeatConfirmBody: 'Вы уверены, что хотите очистить этот пульс?',
heartbeatFiredCount: count => `Сработал ${count} раз`,
actionFailed: msg => `Ошибка действия: ${msg}`,
actionSucceeded: 'Действие выполнено успешно',
copySuccess: 'Критерий скопирован в буфер обмена',
copyFailure: 'Не удалось скопировать критерий в буфер обмена',
continuationFailed: 'Не удалось отправить продолжение цели',
continuationQueued: 'Цель возобновлена — продолжение поставлено в очередь до конца текущего хода',
continuationBusy: 'Цель возобновлена — сессия занята, выполните /interrupt, чтобы продолжить',
controlUnavailable: msg => `Управление сессией недоступно: ${msg}`,
dismissError: 'Скрыть ошибку',
add: 'Добавить'
},
coding: {
title: 'Рабочее дерево',
noBranch: 'Без ветки',
+123
View File
@@ -51,6 +51,39 @@ interface AuxTaskCopy {
}
export interface Translations {
sessionImport: {
title: string
subtitle: string
action: string
readingFrom: string
connectedComputer: string
destination: string
all: string
search: string
scanning: string
scanError: string
scanHelp: string
empty: string
emptyHelp: string
noMatches: string
searchHelp: string
skipped: string
more: string
messages: string
choose: string
chooseHelp: string
previewLoading: string
previewError: string
previewHelp: string
previewLimit: string
you: string
snapshot: string
copyNotice: string
importing: string
open: string
continue: string
importError: string
}
common: {
apply: string
back: string
@@ -2261,6 +2294,7 @@ export interface Translations {
agents: string
background: (count: number) => string
goalActive: string
goalBlocked: string
goalDone: string
goalPaused: string
goalWaiting: string
@@ -2270,6 +2304,95 @@ export interface Translations {
stop: string
dismiss: string
exit: (code: number) => string
control: {
goalActiveTurns: (turn: number, maxTurns: number) => string
goalDoneTurns: (turns: number) => string
goalTurn: (turn: number) => string
goalActions: string
viewDetails: string
addCriterion: string
addCriterionDialogTitle: string
addCriterionPlaceholder: string
criterionLabel: string
pauseGoal: string
resumeGoal: string
resumeNow: string
clearGoal: string
clearGoalConfirmTitle: string
clearGoalConfirmBody: string
copyCriterion: (index: number) => string
removeCriterion: (index: number) => string
removeCriterionConfirmTitle: (index: number) => string
removeCriterionConfirmBody: (index: number) => string
clearCriteria: string
clearCriteriaConfirmTitle: string
clearCriteriaConfirmBody: string
criteriaHeader: (count: number) => string
noCriteria: string
goalDetailsTitle: string
objectiveLabel: string
contractOutcome: string
contractVerification: string
contractConstraints: string
contractBoundaries: string
contractStopWhen: string
waitBarrierTitle: string
waitUntil: (target: string) => string
waitSession: (target: string) => string
waitPid: (pid: number) => string
qualityGatesTitle: string
gateCommand: string
gateAttempts: (attempts: number, max: number) => string
gateTimeout: (seconds: number) => string
gateLastExit: (code: number | null) => string
loopActive: string
loopPaused: string
loopDeferred: string
loopFinished: string
loopRuns: (runs: number) => string
loopRunCount: (current: number, total: number) => string
loopNext: (time: string) => string
loopEverySeconds: (seconds: number) => string
loopEveryMinutes: (minutes: number) => string
loopEveryHours: (hours: number) => string
loopSelfPaced: string
loopActions: string
pauseLoop: string
resumeLoop: string
stopLoop: string
stopLoopConfirmTitle: string
stopLoopConfirmBody: string
dismissLoop: string
loopPromptLabel: string
loopCadenceLabel: string
loopUntilLabel: string
loopDeferredNotice: string
loopAwaitingResponse: string
heartbeatActive: string
heartbeatPaused: string
heartbeatEveryMinutes: (minutes: number) => string
heartbeatEveryHours: (hours: number) => string
heartbeatEverySeconds: (seconds: number) => string
heartbeatNext: (time: string) => string
heartbeatDueWaitingForIdle: string
heartbeatActions: string
pauseHeartbeat: string
resumeHeartbeat: string
clearHeartbeat: string
clearHeartbeatConfirmTitle: string
clearHeartbeatConfirmBody: string
heartbeatFiredCount: (count: number) => string
actionFailed: (msg: string) => string
actionSucceeded: string
copySuccess: string
copyFailure: string
continuationFailed: string
continuationQueued: string
continuationBusy: string
controlUnavailable: (msg: string) => string
dismissError: string
add: string
}
coding: {
title: string
noBranch: string
+125 -1
View File
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale } from './define-locale'
export const zhHant = defineLocale({
sessionImport: {
title: '從其他應用程式繼續',
subtitle: '將對話匯入 Hermes,接著上次的進度繼續。',
action: '匯入工作階段',
readingFrom: '讀取自',
connectedComputer: '已連線的電腦',
destination: '匯入至',
all: '全部',
search: '搜尋已載入的工作階段',
scanning: '正在尋找對話',
scanError: '無法尋找工作階段',
scanHelp: '請檢查後端連線並重試。舊版後端可能需要更新。',
empty: '找不到對話',
emptyHelp: '此後端上的 Claude Code 和 Codex 工作階段將顯示在這裡。',
noMatches: '沒有符合的對話',
searchHelp: '嘗試其他標題或資料夾,或載入更多工作階段。',
skipped: '部分記錄為空白、無法讀取或過大,已略過。',
more: '載入更多工作階段',
messages: '則訊息',
choose: '繼續一段對話',
chooseHelp: '選擇工作階段,在匯入 Hermes 前查看歷程記錄。',
previewLoading: '正在開啟預覽',
previewError: '無法預覽',
previewHelp: '來源檔案可能已移動或變更。請重新整理清單後重試。',
previewLimit: '預覽已縮短,方便閱讀。匯入時會複製完整對話。',
you: '你',
snapshot: '此對話已匯入 Hermes。開啟現有副本即可繼續。',
copyNotice: '複製對話文字,不變更來源檔案。不包含工具輸出和推理內容。',
importing: '正在匯入…',
open: '在 Hermes 中開啟',
continue: '在 Hermes 中繼續',
importError: '無法匯入此對話。'
},
common: {
apply: '套用',
back: '返回',
@@ -1923,7 +1956,8 @@ export const zhHant = defineLocale({
skills: '技能與工具',
messaging: '訊息平台',
artifacts: '成品',
cron: '排程工作'
cron: '排程工作',
'session-import': '匯入工作階段'
},
searchAria: '搜尋工作階段',
searchPlaceholder: '搜尋工作階段…',
@@ -2211,6 +2245,7 @@ export const zhHant = defineLocale({
agents: '代理',
background: count => `${count} 個背景任務`,
goalActive: '目標進行中',
goalBlocked: '目標受阻',
goalDone: '目標已完成',
goalPaused: '目標已暫停',
goalWaiting: '目標等待中',
@@ -2220,6 +2255,95 @@ export const zhHant = defineLocale({
stop: '停止',
dismiss: '關閉',
exit: code => `結束碼 ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 輪`,
goalDoneTurns: turns => `共 ${turns} 輪`,
goalTurn: turn => `第 ${turn} 輪`,
goalActions: '目標操作',
viewDetails: '檢視詳細資訊',
addCriterion: '新增條件',
addCriterionDialogTitle: '新增條件',
addCriterionPlaceholder: '輸入條件內容...',
criterionLabel: '條件',
pauseGoal: '暫停目標',
resumeGoal: '繼續目標',
resumeNow: '立即繼續',
clearGoal: '清除目標',
clearGoalConfirmTitle: '清除目標?',
clearGoalConfirmBody: '確定要清除目前活躍的目標嗎?此動作無法復原。',
copyCriterion: index => `複製條件 ${index}`,
removeCriterion: index => `移除條件 ${index}`,
removeCriterionConfirmTitle: index => `移除條件 ${index}?`,
removeCriterionConfirmBody: index => `確定要移除條件 ${index} 嗎?`,
clearCriteria: '清除全部條件',
clearCriteriaConfirmTitle: '清除全部條件?',
clearCriteriaConfirmBody: '確定要移除此目標下的所有條件嗎?',
criteriaHeader: count => `條件 · ${count}`,
noCriteria: '無條件',
goalDetailsTitle: '目標詳細資訊',
objectiveLabel: '目標內容',
contractOutcome: '預期成果',
contractVerification: '驗證方式',
contractConstraints: '限制條件',
contractBoundaries: '邊界',
contractStopWhen: '停止條件',
waitBarrierTitle: '等待條件',
waitUntil: target => `等待至 ${target}`,
waitSession: target => `等待工作階段 ${target}`,
waitPid: pid => `等待處理程序 ${pid}`,
qualityGatesTitle: '品質關卡',
gateCommand: '命令',
gateAttempts: (attempts, max) => `嘗試 ${attempts}/${max}`,
gateTimeout: seconds => `逾時 ${seconds} 秒`,
gateLastExit: code => (code === null ? '擱置中' : `結束代碼: ${code}`),
loopActive: '循環進行中',
loopPaused: '循環已暫停',
loopDeferred: '循環已延後',
loopFinished: '循環已完成',
loopRuns: runs => `執行 ${runs} 次`,
loopRunCount: (current, total) => `執行 ${current}/${total}`,
loopNext: time => `下次 ${time}`,
loopEverySeconds: seconds => `每 ${seconds} 秒`,
loopEveryMinutes: minutes => `每 ${minutes} 分鐘`,
loopEveryHours: hours => `每 ${hours} 小時`,
loopSelfPaced: '自訂步調',
loopActions: '循環操作',
pauseLoop: '暫停循環',
resumeLoop: '繼續循環',
stopLoop: '停止循環',
stopLoopConfirmTitle: '停止循環?',
stopLoopConfirmBody: '確定要停止此循環嗎?',
dismissLoop: '關閉循環',
loopPromptLabel: '提示詞',
loopCadenceLabel: '頻率',
loopUntilLabel: '截止條件',
loopDeferredNotice: '目前由進行中的目標控制工作階段。',
loopAwaitingResponse: '等待回應中',
heartbeatActive: '活動訊號進行中',
heartbeatPaused: '活動訊號已暫停',
heartbeatEveryMinutes: minutes => `每 ${minutes} 分鐘`,
heartbeatEveryHours: hours => `每 ${hours} 小時`,
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
heartbeatNext: time => `下次 ${time}`,
heartbeatDueWaitingForIdle: '已到期 — 等待會話閒置',
heartbeatActions: '活動訊號操作',
pauseHeartbeat: '暫停活動訊號',
resumeHeartbeat: '繼續活動訊號',
clearHeartbeat: '清除活動訊號',
clearHeartbeatConfirmTitle: '清除活動訊號?',
clearHeartbeatConfirmBody: '確定要清除此活動訊號嗎?',
heartbeatFiredCount: count => `已觸發 ${count} 次`,
actionFailed: msg => `操作失敗: ${msg}`,
actionSucceeded: '操作成功',
copySuccess: '已將條件複製到剪貼板',
copyFailure: '複製條件到剪貼板失敗',
continuationFailed: '提交目標延續內容失敗',
continuationQueued: '目標已恢復 — 延續內容已排隊,將在目前回合結束後送出',
continuationBusy: '目標已恢復 — 會話忙碌中,請 /interrupt 目前回合以繼續',
controlUnavailable: msg => `會話控制無法使用: ${msg}`,
dismissError: '關閉錯誤',
add: '新增'
},
coding: {
title: '工作區',
noBranch: '無分支',
+125 -1
View File
@@ -3,6 +3,39 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import type { Translations } from './types'
export const zh: Translations = {
sessionImport: {
title: '从其他应用继续',
subtitle: '将对话导入 Hermes,接着上次的进度继续。',
action: '导入会话',
readingFrom: '读取自',
connectedComputer: '已连接的计算机',
destination: '导入到',
all: '全部',
search: '搜索已加载的会话',
scanning: '正在查找对话',
scanError: '无法查找会话',
scanHelp: '请检查后端连接并重试。旧版后端可能需要更新。',
empty: '未找到对话',
emptyHelp: '此后端上的 Claude Code 和 Codex 会话将显示在这里。',
noMatches: '没有匹配的对话',
searchHelp: '尝试其他标题或文件夹,或加载更多会话。',
skipped: '部分日志为空、无法读取或过大,已跳过。',
more: '加载更多会话',
messages: '条消息',
choose: '继续一段对话',
chooseHelp: '选择会话,在导入 Hermes 前查看历史记录。',
previewLoading: '正在打开预览',
previewError: '预览不可用',
previewHelp: '源文件可能已移动或更改。请刷新列表后重试。',
previewLimit: '预览已缩短,便于阅读。导入时会复制完整对话。',
you: '你',
snapshot: '此对话已导入 Hermes。打开现有副本即可继续。',
copyNotice: '复制对话文本,不更改源文件。不包含工具输出和推理内容。',
importing: '正在导入…',
open: '在 Hermes 中打开',
continue: '在 Hermes 中继续',
importError: '无法导入此对话。'
},
common: {
apply: '应用',
back: '返回',
@@ -2490,7 +2523,8 @@ export const zh: Translations = {
skills: '技能与工具',
messaging: '消息平台',
artifacts: '产物',
cron: '定时任务'
cron: '定时任务',
'session-import': '导入会话'
},
searchAria: '搜索会话',
searchPlaceholder: '搜索会话…',
@@ -2830,6 +2864,7 @@ export const zh: Translations = {
agents: '代理',
background: count => `${count} 个后台任务`,
goalActive: '目标进行中',
goalBlocked: '目标受阻',
goalDone: '目标已完成',
goalPaused: '目标已暂停',
goalWaiting: '目标等待中',
@@ -2839,6 +2874,95 @@ export const zh: Translations = {
stop: '停止',
dismiss: '关闭',
exit: code => `退出码 ${code}`,
control: {
goalActiveTurns: (turn, maxTurns) => `第 ${turn}/${maxTurns} 轮`,
goalDoneTurns: turns => `共 ${turns} 轮`,
goalTurn: turn => `第 ${turn} 轮`,
goalActions: '目标操作',
viewDetails: '查看详情',
addCriterion: '添加条件',
addCriterionDialogTitle: '添加条件',
addCriterionPlaceholder: '输入条件内容...',
criterionLabel: '条件',
pauseGoal: '暂停目标',
resumeGoal: '恢复目标',
resumeNow: '立即恢复',
clearGoal: '清除目标',
clearGoalConfirmTitle: '清除目标?',
clearGoalConfirmBody: '确定要清除当前活跃的目标吗?此操作无法撤销。',
copyCriterion: index => `复制条件 ${index}`,
removeCriterion: index => `移除条件 ${index}`,
removeCriterionConfirmTitle: index => `移除条件 ${index}?`,
removeCriterionConfirmBody: index => `确定要移除条件 ${index} 吗?`,
clearCriteria: '清除全部条件',
clearCriteriaConfirmTitle: '清除全部条件?',
clearCriteriaConfirmBody: '确定要移除此目标下的所有条件吗?',
criteriaHeader: count => `标准 · ${count}`,
noCriteria: '无条件',
goalDetailsTitle: '目标详情',
objectiveLabel: '目标内容',
contractOutcome: '预期结果',
contractVerification: '验证方式',
contractConstraints: '约束条件',
contractBoundaries: '边界条件',
contractStopWhen: '停止条件',
waitBarrierTitle: '等待状态',
waitUntil: target => `等待至 ${target}`,
waitSession: target => `等待会话 ${target}`,
waitPid: pid => `等待进程 ${pid}`,
qualityGatesTitle: '质量关卡',
gateCommand: '命令',
gateAttempts: (attempts, max) => `尝试 ${attempts}/${max}`,
gateTimeout: seconds => `超时 ${seconds} 秒`,
gateLastExit: code => (code === null ? '等待中' : `退出代码: ${code}`),
loopActive: '循环活跃',
loopPaused: '循环暂停',
loopDeferred: '循环已推迟',
loopFinished: '循环已完成',
loopRuns: runs => `运行 ${runs} 次`,
loopRunCount: (current, total) => `运行 ${current}/${total}`,
loopNext: time => `下次 ${time}`,
loopEverySeconds: seconds => `每 ${seconds} 秒`,
loopEveryMinutes: minutes => `每 ${minutes} 分钟`,
loopEveryHours: hours => `每 ${hours} 小时`,
loopSelfPaced: '自节奏',
loopActions: '循环操作',
pauseLoop: '暂停循环',
resumeLoop: '恢复循环',
stopLoop: '停止循环',
stopLoopConfirmTitle: '停止循环?',
stopLoopConfirmBody: '确定要停止此循环吗?',
dismissLoop: '关闭循环',
loopPromptLabel: '提示词',
loopCadenceLabel: '节奏',
loopUntilLabel: '终止条件',
loopDeferredNotice: '活跃目标当前正在控制会话。',
loopAwaitingResponse: '等待响应中',
heartbeatActive: '心跳活跃',
heartbeatPaused: '心跳暂停',
heartbeatEveryMinutes: minutes => `每 ${minutes} 分钟`,
heartbeatEveryHours: hours => `每 ${hours} 小时`,
heartbeatEverySeconds: seconds => `每 ${seconds} 秒`,
heartbeatNext: time => `下次 ${time}`,
heartbeatDueWaitingForIdle: '已到期,等待会话空闲',
heartbeatActions: '心跳操作',
pauseHeartbeat: '暂停心跳',
resumeHeartbeat: '恢复心跳',
clearHeartbeat: '清除心跳',
clearHeartbeatConfirmTitle: '清除心跳?',
clearHeartbeatConfirmBody: '确定要清除该心跳吗?',
heartbeatFiredCount: count => `已触发 ${count} 次`,
actionFailed: msg => `操作失败: ${msg}`,
actionSucceeded: '操作成功',
copySuccess: '已将条件复制到剪贴板',
copyFailure: '复制条件到剪贴板失败',
continuationFailed: '提交目标后续内容失败',
continuationQueued: '目标已恢复 — 后续内容已排队,将在当前回合结束后发送',
continuationBusy: '目标已恢复 — 会话正忙,请 /interrupt 当前回合以继续',
controlUnavailable: msg => `会话控制不可用: ${msg}`,
dismissError: '关闭错误',
add: '添加'
},
coding: {
title: '工作区',
noBranch: '无分支',
@@ -1342,6 +1342,30 @@ describe('collectUnspokenTurnSpeech', () => {
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
})
it('does not replay earlier turns when the spoken id is missing or stale', () => {
const messages = [
user('u1', 'old question'),
assistant('a1', 'Previous output from last turn.'),
user('u2', 'new question'),
assistant('a2', 'Live reply only.')
]
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
expect(collectUnspokenTurnSpeech(messages, 'vanished-stream-id')?.text).toBe('Live reply only.')
expect(collectUnspokenTurnSpeech(messages, 'a1')?.text).toBe('Live reply only.')
})
it('bounds to a hidden user turn too (widget intents render no bubble)', () => {
const messages = [
user('u1', 'old question'),
assistant('a1', 'Previous output from last turn.'),
{ ...user('u2', 'widget intent'), hidden: true },
assistant('a2', 'Live reply only.')
]
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
})
})
describe('stripPendingClarifyProjectionForCache', () => {
+17 -1
View File
@@ -68,12 +68,28 @@ export interface UnspokenTurnSpeech {
* selecting only one bubble silently drops everything after it. The blank-line
* join is a sentence boundary for the server's cutter, so a sealed bubble's
* tail is flushed as soon as the next bubble starts.
*
* If `lastSpokenId` is missing or stale (session id assigned mid-turn,
* live-tail rewrite missed), do **not** fall back to index -1 — that replays
* every earlier assistant turn as one speech string. Bound to the current
* turn (assistant bubbles after the last user message) instead. Hidden user
* rows count: a widget intent (`display_kind: hidden`) is a real turn for the
* agent even though no bubble renders. A slice with no user row (mid-turn
* interims only) still collects those assistants.
*/
export function collectUnspokenTurnSpeech(
messages: ChatMessage[],
lastSpokenId: string | null
): UnspokenTurnSpeech | null {
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
let spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
if (spokenIndex < 0) {
const lastUser = messages.findLastIndex(m => m.role === 'user')
if (lastUser >= 0) {
spokenIndex = lastUser
}
}
let id: string | null = null
let pending = false
+28
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
absorbSpokenReplyRewrite,
adoptSpokenReplySession,
assistantReplyOrdinal,
clearSpokenRepliesForTests,
isLiveTailReplyId,
@@ -90,3 +91,30 @@ describe('resolveSpokenReply', () => {
expect(spoken?.ordinal).toBe(0)
})
})
describe('adoptSpokenReplySession', () => {
it('moves the null-session anchor onto the created session id', () => {
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
adoptSpokenReplySession(null, 'session-created')
expect(spokenReplyOf('session-created')?.id).toBe('a1')
// Moved, not copied: the next new chat (null session again) starts clean.
expect(spokenReplyOf(null)).toBeNull()
})
it('does not overwrite an anchor the created session already has', () => {
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
markAssistantIdSpoken('session-created', [assistant('a1'), assistant('a2')], 'a2')
adoptSpokenReplySession(null, 'session-created')
expect(spokenReplyOf('session-created')?.id).toBe('a2')
})
it('does not leak a spoken anchor from one real session into another', () => {
markAssistantIdSpoken('session-a', [assistant('a1')], 'a1')
adoptSpokenReplySession('session-a', 'session-b')
expect(spokenReplyOf('session-b')).toBeNull()
expect(spokenReplyOf('session-a')?.id).toBe('a1')
})
})
+32
View File
@@ -111,6 +111,38 @@ export function markAssistantIdSpoken(
markSpokenReply(sessionId, { id, ordinal })
}
/**
* Carry the spoken anchor when a chat gets a real session id (null → created)
* mid voice-conversation. Do not copy across two real sessions — that would
* leak "already spoken" into a different transcript. The null-session entry is
* moved, not copied: left behind, it would mark the NEXT new chat's first reply
* as already spoken.
*/
export function adoptSpokenReplySession(
fromSessionId: string | null | undefined,
toSessionId: string | null | undefined
): void {
const fromKey = sessionKey(fromSessionId)
const toKey = sessionKey(toSessionId)
if (fromKey !== NO_SESSION || toKey === NO_SESSION) {
return
}
const from = lastSpokenBySession.get(fromKey)
if (!from) {
return
}
// Dropped even when not adopted below: the anchor belongs to this chat.
lastSpokenBySession.delete(fromKey)
if (!lastSpokenBySession.has(toKey)) {
lastSpokenBySession.set(toKey, from)
}
}
/** Current spoken anchor, migrated in place when the live row was rewritten. */
export function resolveSpokenReply(
sessionId: string | null | undefined,
@@ -21,6 +21,8 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
interface MentionCompletionItem {
display: string
insert: string
@@ -58,7 +60,7 @@ const ROSTER = {
}
const { cache, hostMock, live } = vi.hoisted(() => {
const live = { focused: 'default', profile: 'default' }
const live = { focused: 'default', profile: 'default', stored: null as string | null }
return {
cache: new Map<string, { key: unknown[]; value: unknown }>(),
@@ -69,7 +71,7 @@ const { cache, hostMock, live } = vi.hoisted(() => {
state: {
connectionId: { get: () => 'local', listen: () => () => undefined },
focusedSessionProfile: { get: () => live.focused, listen: () => () => undefined },
focusedStoredSessionId: { get: () => null, listen: () => () => undefined },
focusedStoredSessionId: { get: () => live.stored, listen: () => () => undefined },
gateway: { get: () => null, listen: () => () => undefined },
profile: { get: () => live.profile, listen: () => () => undefined }
}
@@ -173,6 +175,54 @@ beforeEach(() => {
vi.clearAllMocks()
live.focused = 'default'
live.profile = 'default'
live.stored = null
})
describe('Bot Chat reset guard', () => {
it.each([
{ connectionId: 'local', sourceScoped: true },
{ connectionId: 'remote', remoteSource: true, sourceScoped: true },
{}
])('compacts only the selected bot canonical chat: %j', async source => {
const { handler } = await contributions()
const { $lastRoster } = await import('./data')
const { saveSelectedRosterBot } = await import('./bot-state')
const selected: RosterRow = {
...source,
name: 'writer',
canonical_session: { id: 'bot-root', resolved_id: 'bot-tip' }
}
// An identically named bot on another connection must not win the lookup.
$lastRoster.set([
{ name: 'writer', connectionId: 'other', sourceScoped: true, canonical_session: { id: 'other-chat' } },
selected
])
saveSelectedRosterBot(selected)
for (const stored of ['bot-root', 'bot-tip']) {
live.stored = stored
for (const text of ['/new', '/reset']) {
hostMock.notify.mockClear()
expect(await handler({ text })).toEqual({ text: '/compact' })
expect(hostMock.notify).toHaveBeenCalledOnce()
expect(hostMock.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'This chat never resets' }))
}
}
for (const stored of ['scratch-chat', 'other-chat', null]) {
live.stored = stored
for (const text of ['/new', '/reset']) {
hostMock.notify.mockClear()
const draft = { text }
expect(await handler(draft)).toBe(draft)
expect(hostMock.notify).not.toHaveBeenCalled()
}
}
})
})
describe('@-mention completions', () => {
@@ -37,6 +37,7 @@ import {
$lastRoster,
botHandle,
botMentionTag,
botSelectionKey,
cachedUnionRoster,
isActiveRosterBot,
migrateBotMeta,
@@ -665,7 +666,7 @@ export default {
// server-side by name), matching either the durable row id or
// the compression-lineage tip currently on screen.
const roster = $lastRoster.get()
const row = Array.isArray(roster) ? roster.find(bot => bot?.name === activeBot) : null
const row = Array.isArray(roster) ? roster.find(bot => botSelectionKey(bot) === activeBot) : null
// The STORED id, which is the id space canonical_session is keyed
// in. `host.state.activeSessionId` is the runtime id and could
+5 -2
View File
@@ -911,11 +911,14 @@ export const host = {
// not the registry-secondary path openGatewayForAgent takes for a 'local'
// connection id. Behavior for a plain local open is unchanged.
const dial = explicitRoute
? () => openGatewayForAgent(explicitRoute.connectionId, explicitRoute.profile)
? () =>
openGatewayForAgent(explicitRoute.connectionId, explicitRoute.profile, {
spawnPriority: 'foreground'
})
: plan.switchWorkspace
? () => ensureGatewayProfile(plan.switchWorkspace as string)
: plan.dialWithoutSwitching
? () => openGatewayForProfile(plan.dialWithoutSwitching as string)
? () => openGatewayForProfile(plan.dialWithoutSwitching as string, { spawnPriority: 'foreground' })
: null
if (dial) {
+13 -3
View File
@@ -607,7 +607,11 @@ describe('profile-aware plugin session opens', () => {
await host.openSession('remote-chat', { route })
expect(openGatewayForAgent).toHaveBeenCalledWith('source-a', 'default')
expect(openGatewayForAgent).toHaveBeenCalledWith(
'source-a',
'default',
expect.objectContaining({ spawnPriority: 'foreground' })
)
expect(ensureGatewayProfile).not.toHaveBeenCalled()
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
expect($activeGatewayProfile.get()).toBe('remote-worker')
@@ -1158,7 +1162,10 @@ describe('profile-aware plugin session opens', () => {
})
expect(ensureGatewayProfile).not.toHaveBeenCalled()
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
expect(openGatewayForProfile).toHaveBeenCalledWith(
'worker',
expect.objectContaining({ spawnPriority: 'foreground' })
)
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
expect($activeGatewayProfile.get()).toBe('default')
})
@@ -1169,7 +1176,10 @@ describe('profile-aware plugin session opens', () => {
await host.openSession('bot-chat', { profile: 'worker' })
expect(ensureGatewayProfile).not.toHaveBeenCalled()
expect(openGatewayForProfile).toHaveBeenCalledWith('worker')
expect(openGatewayForProfile).toHaveBeenCalledWith(
'worker',
expect.objectContaining({ spawnPriority: 'foreground' })
)
expect(setShowAllProfiles).toHaveBeenCalledWith(true)
expect($activeGatewayProfile.get()).toBe('default')
})
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// #102281: a user-initiated open must reach Electron main as a FOREGROUND dial
// on its FIRST IPC, not only on the secondary's connect. Every open first
// probes the route (sharedPrimaryRoute / isAttachedSharedRemote) with
// getConnection / getConnectionFor; if that probe is untagged, main starts the
// spawn as a background slot wait and the click waits out the probe's 20s
// timeout before anything promotes it.
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (): Promise<void> => {
this.connectionState = 'open'
}
close = (): void => {
this.connectionState = 'closed'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
openGatewayForProfile,
setPrimaryGateway
} = await import('./gateway')
const conn = {
authMode: 'token',
baseUrl: 'https://homelab.invalid',
mode: 'remote',
profile: 'research',
token: 'fake-test-token',
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
}
function installDesktop(): { getConnection: ReturnType<typeof vi.fn>; getConnectionFor: ReturnType<typeof vi.fn> } {
const stub = {
getConnection: vi.fn(async () => conn),
getConnectionFor: vi.fn(async () => conn)
}
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
return stub
}
function priorities(mock: ReturnType<typeof vi.fn>, pick: (args: unknown[]) => unknown): unknown[] {
return mock.mock.calls.map(args => pick(args))
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() })
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('user opens dial main as foreground from the first IPC (#102281)', () => {
it('ensureGatewayForProfile tags the route probe AND the connect dial', async () => {
const desktop = installDesktop()
await ensureGatewayForProfile('research')
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
expect(seen.length).toBeGreaterThanOrEqual(2)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
it('openGatewayForProfile without a priority never tags a dial as foreground', async () => {
const desktop = installDesktop()
await openGatewayForProfile('research')
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === undefined)).toBe(true)
})
it('openGatewayForAgent forwards spawnPriority to every registry dial', async () => {
const desktop = installDesktop()
await openGatewayForAgent('homelab', 'research', { spawnPriority: 'foreground' })
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
it('ensureGatewayForAgent is always a foreground open', async () => {
const desktop = installDesktop()
await ensureGatewayForAgent('homelab', 'research')
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
})
+4
View File
@@ -23,6 +23,7 @@ import {
setSessions,
setSessionsLoading
} from '@/store/session'
import { clearAllSessionControl } from '@/store/session-control'
import { resetSessionPinMirror } from '@/store/session-pin-sync'
import { clearAllSessionStates } from '@/store/session-states'
import { clearTranscriptTails } from '@/store/transcript-tail-cache'
@@ -206,6 +207,9 @@ export function wipeSessionListsForGatewaySwitch(): void {
// that are still unread once the next gateway's lists load — so a profile
// round-trip doesn't swallow green dots.
clearAllSessionStates()
// Structured goal/loop/heartbeat entries are keyed by runtime id, which the
// next backend re-mints, so a full wipe is exact (and stale-response-safe).
clearAllSessionControl()
resetLiveRuntimeTracking()
resetLiveSync()
$unreadFinishedSessionIds.set([])
+72 -20
View File
@@ -20,6 +20,26 @@ import { setConnection, setGatewayState } from '@/store/session'
const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default'
// Spawn-slot priority handed to Electron main with every backend dial. A
// user-initiated open is 'foreground' and may take the pool's reserved slot;
// roster hydration, hover prewarm and untagged dials are 'background' — main's
// default, so background dials keep the pre-priority IPC payload shape.
type SpawnPriority = 'foreground' | 'background'
function dialPriority(spawnPriority: SpawnPriority): { priority: 'foreground' } | Record<never, never> {
return spawnPriority === 'foreground' ? { priority: 'foreground' } : {}
}
function dialProfile(
desktop: NonNullable<typeof window.hermesDesktop>,
profile: string,
spawnPriority: SpawnPriority
): Promise<HermesConnection> {
return spawnPriority === 'foreground'
? desktop.getConnection(profile, { priority: 'foreground' })
: desktop.getConnection(profile)
}
// Read connection state through a call so TS control-flow analysis doesn't
// narrow the getter to a constant across guards (it genuinely changes).
const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open'
@@ -302,7 +322,11 @@ function isPrimaryRegistryRoute(connectionId: null | string, profile: string): b
* dials a second WebSocket at the same Tailscale URL, which accept/closes in
* ~30ms (`messages=1`) and never runs `session.create` (#96493). Isolated
* SSH/pooled backends (`sharedRemote: false`) still get their own secondary. */
async function isAttachedSharedRemote(connectionId: null | string, profile: string): Promise<boolean> {
async function isAttachedSharedRemote(
connectionId: null | string,
profile: string,
spawnPriority: SpawnPriority = 'background'
): Promise<boolean> {
const id = String(connectionId ?? '').trim()
const key = normKey(profile)
@@ -322,7 +346,7 @@ async function isAttachedSharedRemote(connectionId: null | string, profile: stri
try {
const conn = await withTimeout(
desktop.getConnectionFor({ connectionId: id, profile: key }),
desktop.getConnectionFor({ connectionId: id, profile: key, ...dialPriority(spawnPriority) }),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out resolving shared-remote route for "${key}"`
)
@@ -489,7 +513,7 @@ function clearTimer(entry: Secondary): void {
}
}
async function openSecondary(entry: Secondary): Promise<void> {
async function openSecondary(entry: Secondary, spawnPriority: SpawnPriority = 'background'): Promise<void> {
const desktop = window.hermesDesktop
if (!desktop) {
@@ -497,6 +521,20 @@ async function openSecondary(entry: Secondary): Promise<void> {
}
if (entry.connectPromise) {
if (spawnPriority === 'foreground') {
// Hydration may already own this dial as a background slot wait. Kick a
// foreground IPC so main can promote it onto the reserved slot.
void (
entry.connectionId && desktop.getConnectionFor
? desktop.getConnectionFor({
connectionId: entry.connectionId,
profile: entry.profile,
priority: 'foreground'
})
: desktop.getConnection(entry.profile, { priority: 'foreground' })
).catch(() => undefined)
}
await entry.connectPromise
return
@@ -543,12 +581,16 @@ async function openSecondary(entry: Secondary): Promise<void> {
const conn =
entry.connectionId && desktop.getConnectionFor
? await withTimeout(
desktop.getConnectionFor({ connectionId: entry.connectionId, profile: entry.profile }),
desktop.getConnectionFor({
connectionId: entry.connectionId,
profile: entry.profile,
...dialPriority(spawnPriority)
}),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
: await withTimeout(
desktop.getConnection(entry.profile),
dialProfile(desktop, entry.profile, spawnPriority),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
@@ -752,7 +794,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
// the second dial fails (tunnel/token are per-backend) and the closed socket
// poisons the active gateway with "not connected" even though the primary is
// open right next to it.
async function sharedPrimaryRoute(profile: string): Promise<boolean> {
async function sharedPrimaryRoute(profile: string, spawnPriority: SpawnPriority = 'background'): Promise<boolean> {
const desktop = window.hermesDesktop
if (!desktop) {
@@ -764,8 +806,11 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
// like any other failure, not hang the route decision forever, since
// every caller (gatewayForProfile → requestGatewayForProfile/Agent) awaits
// this before it can fall back to dialing a secondary.
// This is the FIRST dial main sees for a user open, so it must already
// carry the foreground priority — otherwise the spawn it starts queues as
// background and the click waits out this probe before being promoted.
const conn = await withTimeout(
desktop.getConnection(profile),
dialProfile(desktop, profile, spawnPriority),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out resolving the shared-primary route for profile "${profile}"`
)
@@ -781,7 +826,8 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
// request-scope flag; dedicated local/remote profiles use their pooled socket.
async function gatewayForProfile(
profile: string,
leaseRequest = false
leaseRequest = false,
spawnPriority: SpawnPriority = 'background'
): Promise<{ gateway: HermesGateway | null; key: string; release: () => void; scopeProfile: boolean }> {
const key = normKey(profile)
const noRelease = () => undefined
@@ -790,7 +836,7 @@ async function gatewayForProfile(
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: false }
}
if (await sharedPrimaryRoute(key)) {
if (await sharedPrimaryRoute(key, spawnPriority)) {
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: true }
}
@@ -840,7 +886,7 @@ async function gatewayForProfile(
try {
if (!isOpen(entry.gateway)) {
await openSecondary(entry)
await openSecondary(entry, spawnPriority)
}
} catch (error) {
release()
@@ -1300,8 +1346,11 @@ function releaseTerminalTurnLease(scope: string, event: GatewayEvent): void {
// it. No scheduleReconnect on failure: a hover is speculative, so a dead
// backend must not start a background retry loop — the real switch owns retry
// and error UX. An already-open (or primary) profile is a no-op.
export async function openGatewayForProfile(profile: string): Promise<void> {
await gatewayForProfile(profile)
export async function openGatewayForProfile(
profile: string,
{ spawnPriority = 'background' }: { spawnPriority?: SpawnPriority } = {}
): Promise<void> {
await gatewayForProfile(profile, false, spawnPriority)
}
// ── Connection-scoped agents (multi-source roster) ─────────────────────────
@@ -1321,15 +1370,18 @@ export async function openGatewayForProfile(profile: string): Promise<void> {
export async function openGatewayForAgent(
connectionId: null | string,
profile: string,
{ activationLease = false }: { activationLease?: boolean } = {}
{
activationLease = false,
spawnPriority = 'background'
}: { activationLease?: boolean; spawnPriority?: SpawnPriority } = {}
): Promise<void> {
const scope = registryBackendScopeKey(connectionId, profile)
if (scope === normKey(profile) || isPrimaryRegistryRoute(connectionId, profile)) {
return openGatewayForProfile(profile)
return openGatewayForProfile(profile, { spawnPriority })
}
if (await isAttachedSharedRemote(connectionId, profile)) {
if (await isAttachedSharedRemote(connectionId, profile, spawnPriority)) {
if (!isOpen(g.primaryGateway)) {
throw new Error('Hermes gateway unavailable')
}
@@ -1356,7 +1408,7 @@ export async function openGatewayForAgent(
}
try {
await openSecondary(entry)
await openSecondary(entry, spawnPriority)
} catch (error) {
if (activationLease) {
entry.activationLeaseUntil = 0
@@ -1383,7 +1435,7 @@ export async function ensureGatewayForAgent(
return !signal?.aborted
}
if (await isAttachedSharedRemote(connectionId, profile)) {
if (await isAttachedSharedRemote(connectionId, profile, 'foreground')) {
return Boolean(isOpen(g.primaryGateway) && !signal?.aborted)
}
@@ -1412,7 +1464,7 @@ export async function ensureGatewayForAgent(
entry.reconnectAttempt = 0
try {
await openSecondary(entry)
await openSecondary(entry, 'foreground')
} catch {
scheduleReconnect(entry)
}
@@ -1465,7 +1517,7 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
// primary instead of dialing a doomed duplicate socket at the same
// descriptor — $activeGatewayProfile still moves to `key`, so request
// scoping and profile-aware surfaces behave identically.
if (await sharedPrimaryRoute(key)) {
if (await sharedPrimaryRoute(key, 'foreground')) {
applyActive(g.primaryProfile, activationEpoch)
return
@@ -1489,7 +1541,7 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
entry.reconnectAttempt = 0
try {
await openSecondary(entry)
await openSecondary(entry, 'foreground')
} catch (error) {
// #81094: a failed secondary dial must NOT fall through to setActive()
// with a closed socket — that silently routes the user's messages to the
+4 -1
View File
@@ -603,7 +603,10 @@ export async function openGatewayAgent(connectionId: string, profile: string): P
return
}
await openGatewayForAgent(connection, normalizeProfileKey(profile), { activationLease: true })
await openGatewayForAgent(connection, normalizeProfileKey(profile), {
activationLease: true,
spawnPriority: 'foreground'
})
}
// Activate a connection-scoped agent's gateway — the (connectionId, profile)
@@ -0,0 +1,472 @@
import { JsonRpcGatewayError } from '@hermes/shared'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { refreshLegacyGoal } = vi.hoisted(() => ({ refreshLegacyGoal: vi.fn() }))
vi.mock('./goals', async importOriginal => ({
...(await importOriginal()),
refreshSessionGoal: refreshLegacyGoal
}))
import { $gateway } from './gateway'
import { resetBackgroundPollingGuard } from './runtime-gone'
import {
$sessionControlBySession,
applySessionControlSnapshot,
applySessionControlUpdate,
clearAllSessionControl,
clearSessionControl,
parseSessionControlSnapshot,
refreshSessionControl,
refreshSupportedSessionControlAfterTurn,
runSessionControlAction,
type SessionControlSnapshot
} from './session-control'
const FULL_SNAPSHOT: SessionControlSnapshot = {
goal: {
contract: {
boundaries: 'desktop store only',
constraints: 'do not lose state',
outcome: 'session control is hydrated',
stop_when: 'a human decision is required',
verification: 'focused tests pass'
},
created_at: 1_700_000_000,
gates: [{ attempts: 0, command: 'npm test', last_exit_code: null, max_retries: 2, timeout_seconds: 60 }],
max_turns: 20,
status: 'active',
subgoals: ['write tests', 'repair state'],
title: 'Repair session control',
turns_used: 3,
updated_at: 1_700_000_100,
wait_barrier: { reason: 'waiting for deploy', type: 'until', until_at: 1_700_000_200 }
},
heartbeat: {
created_at: 1_700_000_000,
fire_count: 5,
interval_seconds: 600,
last_fired_at: 1_700_000_100,
prompt: 'check health',
status: 'active'
},
loop: {
awaiting_response: false,
created_at: 1_700_000_000,
current_delay: 300,
deferred_by_goal: false,
interval_seconds: 300,
last_fired_at: 1_700_000_100,
max_ticks: 10,
mode: 'interval',
next_due_at: 1_700_000_400,
prompt: 'check build status',
status: 'active',
ticks_fired: 3,
times: 3,
until: ''
},
revision: 'revision-1',
updated_at: 1_700_000_100
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
function useGateway(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): void {
$gateway.set({ request } as never)
}
describe('session-control store', () => {
beforeEach(() => {
refreshLegacyGoal.mockReset()
clearAllSessionControl()
})
afterEach(() => {
$gateway.set(null as never)
resetBackgroundPollingGuard()
clearAllSessionControl()
})
it('parses the exact persisted goal, loop, heartbeat, and wait-barrier shapes into fresh data', () => {
const parsed = parseSessionControlSnapshot(FULL_SNAPSHOT)
expect(parsed).toEqual(FULL_SNAPSHOT)
expect(parsed).not.toBe(FULL_SNAPSHOT)
expect(parsed!.goal).not.toBe(FULL_SNAPSHOT.goal)
expect(parsed!.goal!.contract).not.toBe(FULL_SNAPSHOT.goal!.contract)
expect(parsed!.loop!.mode).toBe('interval')
expect(parsed!.goal!.wait_barrier).toEqual({ reason: 'waiting for deploy', type: 'until', until_at: 1_700_000_200 })
})
it.each([
['unknown goal status', { ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, status: 'waiting' } }],
['non-finite top-level timestamp', { ...FULL_SNAPSHOT, updated_at: Number.NaN }],
['malformed goal contract', { ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, contract: { outcome: 3 } } }],
[
'gate output that is not in the allowlisted summary',
{
...FULL_SNAPSHOT,
goal: { ...FULL_SNAPSHOT.goal!, gates: [{ ...FULL_SNAPSHOT.goal!.gates[0], last_output_tail: 'leak' }] }
}
],
[
'wait target that does not match its discriminator',
{ ...FULL_SNAPSHOT, goal: { ...FULL_SNAPSHOT.goal!, wait_barrier: { reason: 'pid', target: '7', type: 'pid' } } }
],
['unknown loop mode', { ...FULL_SNAPSHOT, loop: { ...FULL_SNAPSHOT.loop!, mode: 'fixed' } }],
['malformed heartbeat', { ...FULL_SNAPSHOT, heartbeat: { ...FULL_SNAPSHOT.heartbeat!, fire_count: 'five' } }],
['unknown top-level field', { ...FULL_SNAPSHOT, unsupported: true }]
])('rejects %s without accepting a partial snapshot', (_name, value) => {
expect(parseSessionControlSnapshot(value)).toBeNull()
})
it('falls back once on the unsupported transition and suppresses future compatibility retries', async () => {
const request = vi.fn(async () => {
throw new JsonRpcGatewayError('method not found', { code: -32601 })
})
useGateway(request)
await refreshSessionControl('s1')
await refreshSessionControl('s1')
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'unsupported',
loading: false,
snapshot: null
})
expect(refreshLegacyGoal).toHaveBeenCalledTimes(1)
expect(refreshLegacyGoal).toHaveBeenCalledWith('s1')
expect(request).toHaveBeenCalledTimes(1)
})
it('retains the last good snapshot and reports a bounded ordinary read error', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
const message = 'x'.repeat(500)
useGateway(
vi.fn(async () => {
throw new Error(message)
})
)
await expect(refreshSessionControl('s1', { background: true })).resolves.toBeDefined()
const entry = $sessionControlBySession.get().s1!
expect(entry.snapshot!.revision).toBe(FULL_SNAPSHOT.revision)
expect(entry.capability).toBe('supported')
expect(entry.error).toHaveLength(240)
expect(entry.loading).toBe(false)
})
it('downgrades a current action method-not-found once, preserves its snapshot, and still rejects', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
useGateway(
vi.fn(async () => {
throw new JsonRpcGatewayError('method not found', { code: -32601 })
})
)
await expect(runSessionControlAction('s1', 'goal.pause')).rejects.toThrow('method not found')
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'unsupported',
error: null,
loading: false,
pendingAction: null,
snapshot: { revision: FULL_SNAPSHOT.revision }
})
expect(refreshLegacyGoal).toHaveBeenCalledTimes(1)
expect(refreshLegacyGoal).toHaveBeenCalledWith('s1')
})
it('does not let a method-not-found action response downgrade a newer event snapshot', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
const response = deferred<unknown>()
useGateway(vi.fn(() => response.promise))
const action = runSessionControlAction('s1', 'goal.pause')
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
response.reject(new JsonRpcGatewayError('method not found', { code: -32601 }))
await expect(action).rejects.toThrow('method not found')
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'supported',
error: 'method not found',
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
expect(refreshLegacyGoal).not.toHaveBeenCalled()
})
it('does not let a stale method-not-found response disturb a newer action', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
const olderResponse = deferred<unknown>()
const newerResponse = deferred<unknown>()
useGateway(
vi
.fn()
.mockImplementationOnce(() => olderResponse.promise)
.mockImplementationOnce(() => newerResponse.promise)
)
const olderAction = runSessionControlAction('s1', 'goal.pause')
const newerAction = runSessionControlAction('s1', 'goal.resume')
olderResponse.reject(new JsonRpcGatewayError('method not found', { code: -32601 }))
await expect(olderAction).rejects.toThrow('method not found')
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'supported',
error: null,
loading: true,
pendingAction: 'goal.resume',
snapshot: { revision: FULL_SNAPSHOT.revision }
})
expect(refreshLegacyGoal).not.toHaveBeenCalled()
newerResponse.resolve({
control: { ...FULL_SNAPSHOT, revision: 'newer-action' },
dispatch: { display: null, message: null, notice: null, output: 'resumed', type: 'exec' }
})
await expect(newerAction).resolves.toMatchObject({ output: 'resumed' })
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'newer-action' }
})
})
it('submits only the requested action and exposes its pending state on that session', async () => {
const response = deferred<unknown>()
const request = vi.fn(() => response.promise)
useGateway(request)
const action = runSessionControlAction('s1', 'subgoal.add', { text: 'verify hydration' })
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'unknown',
loading: true,
pendingAction: 'subgoal.add'
})
expect($sessionControlBySession.get().s2).toBeUndefined()
expect(request).toHaveBeenCalledWith('session.control', {
action: 'subgoal.add',
args: { text: 'verify hydration' },
session_id: 's1'
})
response.resolve({
control: { ...FULL_SNAPSHOT, revision: 'action-revision' },
dispatch: { display: null, message: null, notice: null, output: 'added', type: 'exec' }
})
await expect(action).resolves.toEqual({ display: null, message: null, notice: null, output: 'added', type: 'exec' })
expect($sessionControlBySession.get().s1).toMatchObject({
capability: 'supported',
error: null,
pendingAction: null
})
})
it('rejects a failed action truthfully while retaining the good snapshot and clearing its pending state', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
useGateway(
vi.fn(async () => {
throw new Error('backend unavailable')
})
)
await expect(runSessionControlAction('s1', 'goal.pause')).rejects.toThrow('backend unavailable')
expect($sessionControlBySession.get().s1).toMatchObject({ error: 'backend unavailable', pendingAction: null })
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe(FULL_SNAPSHOT.revision)
})
it('keeps a pending action current through an event and publishes its later failure', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
const response = deferred<unknown>()
useGateway(vi.fn(() => response.promise))
const action = runSessionControlAction('s1', 'goal.pause')
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: 'goal.pause' })
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
expect($sessionControlBySession.get().s1).toMatchObject({
error: null,
loading: true,
pendingAction: 'goal.pause',
snapshot: { revision: 'event-newer' }
})
response.reject(new Error('backend unavailable'))
await expect(action).rejects.toThrow('backend unavailable')
expect($sessionControlBySession.get().s1).toMatchObject({
error: 'backend unavailable',
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
})
it('does not let a late read overwrite a newer event', async () => {
const slow = deferred<unknown>()
useGateway(vi.fn(() => slow.promise))
const read = refreshSessionControl('s1')
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: null })
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'read-stale' } })
await read
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
})
it('does not let a late read overwrite a newer action response', async () => {
const slow = deferred<unknown>()
const request = vi
.fn()
.mockImplementationOnce(() => slow.promise)
.mockResolvedValueOnce({
control: { ...FULL_SNAPSHOT, revision: 'action-newer' },
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
})
useGateway(request)
const read = refreshSessionControl('s1')
await runSessionControlAction('s1', 'goal.pause')
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'read-stale' } })
await read
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe('action-newer')
})
it('a gateway-switch wipe drops every entry and strands in-flight responses from the old backend', async () => {
applySessionControlSnapshot('supported', FULL_SNAPSHOT)
const slowRead = deferred<unknown>()
const slowAction = deferred<unknown>()
useGateway(vi.fn((method: string) => (method === 'session.control' ? slowAction.promise : slowRead.promise)))
const read = refreshSessionControl('probing')
const action = runSessionControlAction('supported', 'goal.pause')
clearAllSessionControl()
expect($sessionControlBySession.get()).toEqual({})
slowRead.resolve({ control: FULL_SNAPSHOT })
slowAction.resolve({
control: { ...FULL_SNAPSHOT, revision: 'stale-action' },
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
})
await read
await action
expect($sessionControlBySession.get()).toEqual({})
})
it('does not let a late read repopulate a cleared session', async () => {
const slow = deferred<unknown>()
useGateway(vi.fn(() => slow.promise))
const read = refreshSessionControl('s1')
clearSessionControl('s1')
slow.resolve({ control: FULL_SNAPSHOT })
await read
expect($sessionControlBySession.get().s1).toBeUndefined()
})
it('does not let an older read overwrite a newer read', async () => {
const slow = deferred<unknown>()
const fast = deferred<unknown>()
const request = vi
.fn()
.mockImplementationOnce(() => slow.promise)
.mockImplementationOnce(() => fast.promise)
useGateway(request)
const older = refreshSessionControl('s1')
const newer = refreshSessionControl('s1')
fast.resolve({ control: { ...FULL_SNAPSHOT, revision: 'newer-read' } })
await newer
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'older-read' } })
await older
expect($sessionControlBySession.get().s1!.snapshot!.revision).toBe('newer-read')
})
it('does not let a late post-turn refresh overwrite a newer event', async () => {
applySessionControlSnapshot('s1', FULL_SNAPSHOT)
const slow = deferred<unknown>()
useGateway(vi.fn(() => slow.promise))
const refresh = refreshSupportedSessionControlAfterTurn('s1')
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
slow.resolve({ control: { ...FULL_SNAPSHOT, revision: 'post-turn-stale' } })
await refresh
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'event-newer' }
})
})
it('applies an authoritative action response after an intermediate event', async () => {
const slow = deferred<unknown>()
useGateway(vi.fn(() => slow.promise))
const action = runSessionControlAction('s1', 'goal.pause')
expect($sessionControlBySession.get().s1).toMatchObject({ loading: true, pendingAction: 'goal.pause' })
applySessionControlUpdate('s1', { ...FULL_SNAPSHOT, revision: 'event-newer' })
expect($sessionControlBySession.get().s1).toMatchObject({
loading: true,
pendingAction: 'goal.pause',
snapshot: { revision: 'event-newer' }
})
slow.resolve({
control: { ...FULL_SNAPSHOT, revision: 'action-stale' },
dispatch: { display: null, message: null, notice: null, output: 'paused', type: 'exec' }
})
await expect(action).resolves.toMatchObject({ output: 'paused', type: 'exec' })
expect($sessionControlBySession.get().s1).toMatchObject({
loading: false,
pendingAction: null,
snapshot: { revision: 'action-stale' }
})
})
})
+872
View File
@@ -0,0 +1,872 @@
import { atom } from 'nanostores'
import { $gateway } from './gateway'
import { refreshSessionGoal } from './goals'
import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone'
import { ambientRequestFor } from './session-gone-latch'
import { requestForOwnedSession } from './session-states'
export type SessionControlGoalStatus = 'active' | 'done' | 'paused'
export type SessionControlLoopMode = 'interval' | 'self_paced'
export type SessionControlLoopStatus = 'active' | 'done' | 'paused'
export type SessionControlHeartbeatStatus = 'active' | 'paused'
export interface SessionControlGoalContract {
boundaries: string
constraints: string
outcome: string
stop_when: string
verification: string
}
export interface SessionControlGate {
attempts: number
command: string
last_exit_code: number | null
max_retries: number
timeout_seconds: number
}
export type SessionControlWaitBarrier =
| { reason: string; type: 'until'; until_at: number }
| { reason: string; target: string; type: 'session' }
| { reason: string; target: number; type: 'pid' }
export interface SessionControlGoal {
contract: SessionControlGoalContract
gates: SessionControlGate[]
last_reason?: string
last_verdict?: 'blocked' | 'continue' | 'done' | 'skipped' | 'wait'
max_turns: number
paused_reason?: string
status: SessionControlGoalStatus
subgoals: string[]
title: string
turns_used: number
updated_at?: number
wait_barrier?: SessionControlWaitBarrier
created_at?: number
}
export interface SessionControlLoop {
awaiting_response: boolean
created_at: number
current_delay: number
deferred_by_goal: boolean
interval_seconds: number
last_fired_at: number
last_stop_reason?: string
max_ticks: number
mode: SessionControlLoopMode
next_due_at: number
paused_reason?: string
prompt: string
status: SessionControlLoopStatus
ticks_fired: number
times: number
until: string
}
export interface SessionControlHeartbeat {
created_at: number
fire_count: number
interval_seconds: number
last_fired_at: number
prompt: string
status: SessionControlHeartbeatStatus
}
export interface SessionControlSnapshot {
goal: SessionControlGoal | null
heartbeat: SessionControlHeartbeat | null
loop: SessionControlLoop | null
revision: string
updated_at: number
}
export type SessionControlAction =
| 'goal.clear'
| 'goal.pause'
| 'goal.resume'
| 'goal.unwait'
| 'heartbeat.clear'
| 'heartbeat.pause'
| 'heartbeat.resume'
| 'loop.pause'
| 'loop.resume'
| 'loop.stop'
| 'subgoal.add'
| 'subgoal.clear'
| 'subgoal.remove'
export type SessionControlActionArgs = { index: number } | { text: string }
export interface SessionControlDispatch {
display: string | null
message: string | null
notice: string | null
output: string | null
type: 'exec' | 'send'
}
export interface SessionControlEntry {
capability: 'unknown' | 'supported' | 'unsupported'
error: string | null
loading: boolean
pendingAction: SessionControlAction | null
snapshot: SessionControlSnapshot | null
}
interface RefreshOptions {
background?: boolean
}
type UnknownRecord = Record<string, unknown>
const GOAL_STATUSES = new Set<SessionControlGoalStatus>(['active', 'done', 'paused'])
const GOAL_VERDICTS = new Set<NonNullable<SessionControlGoal['last_verdict']>>([
'blocked',
'continue',
'done',
'skipped',
'wait'
])
const LOOP_MODES = new Set<SessionControlLoopMode>(['interval', 'self_paced'])
const LOOP_STATUSES = new Set<SessionControlLoopStatus>(['active', 'done', 'paused'])
const HEARTBEAT_STATUSES = new Set<SessionControlHeartbeatStatus>(['active', 'paused'])
const DISPATCH_TYPES = new Set<SessionControlDispatch['type']>(['exec', 'send'])
const ERROR_LIMIT = 240
const versions = new Map<string, number>()
const eventVersions = new Map<string, number>()
export const $sessionControlBySession = atom<Record<string, SessionControlEntry>>({})
function isRecord(value: unknown): value is UnknownRecord {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function hasOwn(value: UnknownRecord, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key)
}
function hasExactFields(value: UnknownRecord, required: string[], optional: string[] = []): boolean {
const allowed = new Set([...required, ...optional])
return required.every(key => hasOwn(value, key)) && Object.keys(value).every(key => allowed.has(key))
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isInteger(value: unknown): value is number {
return Number.isInteger(value)
}
function hasOptionalStrings(value: UnknownRecord, keys: string[]): boolean {
return keys.every(key => !hasOwn(value, key) || typeof value[key] === 'string')
}
function parseGoalContract(value: unknown): SessionControlGoalContract | null {
if (
!isRecord(value) ||
!hasExactFields(value, ['outcome', 'verification', 'constraints', 'boundaries', 'stop_when'])
) {
return null
}
if (
typeof value.outcome !== 'string' ||
typeof value.verification !== 'string' ||
typeof value.constraints !== 'string' ||
typeof value.boundaries !== 'string' ||
typeof value.stop_when !== 'string'
) {
return null
}
return {
boundaries: value.boundaries,
constraints: value.constraints,
outcome: value.outcome,
stop_when: value.stop_when,
verification: value.verification
}
}
function parseGate(value: unknown): SessionControlGate | null {
if (
!isRecord(value) ||
!hasExactFields(value, ['command', 'timeout_seconds', 'max_retries', 'attempts', 'last_exit_code'])
) {
return null
}
if (
typeof value.command !== 'string' ||
!isInteger(value.timeout_seconds) ||
!isInteger(value.max_retries) ||
!isInteger(value.attempts) ||
(value.last_exit_code !== null && !isInteger(value.last_exit_code))
) {
return null
}
return {
attempts: value.attempts,
command: value.command,
last_exit_code: value.last_exit_code,
max_retries: value.max_retries,
timeout_seconds: value.timeout_seconds
}
}
function parseWaitBarrier(value: unknown): SessionControlWaitBarrier | null {
if (!isRecord(value) || typeof value.type !== 'string') {
return null
}
if (value.type === 'until') {
if (
!hasExactFields(value, ['type', 'until_at', 'reason']) ||
!isFiniteNumber(value.until_at) ||
typeof value.reason !== 'string'
) {
return null
}
return { reason: value.reason, type: 'until', until_at: value.until_at }
}
if (value.type === 'session') {
if (
!hasExactFields(value, ['type', 'target', 'reason']) ||
typeof value.target !== 'string' ||
typeof value.reason !== 'string'
) {
return null
}
return { reason: value.reason, target: value.target, type: 'session' }
}
if (value.type === 'pid') {
if (
!hasExactFields(value, ['type', 'target', 'reason']) ||
!isInteger(value.target) ||
typeof value.reason !== 'string'
) {
return null
}
return { reason: value.reason, target: value.target, type: 'pid' }
}
return null
}
function parseGoal(value: unknown): SessionControlGoal | null {
const required = ['title', 'status', 'turns_used', 'max_turns', 'contract', 'subgoals', 'gates']
const optional = ['created_at', 'updated_at', 'paused_reason', 'last_verdict', 'last_reason', 'wait_barrier']
if (!isRecord(value) || !hasExactFields(value, required, optional)) {
return null
}
if (
typeof value.title !== 'string' ||
typeof value.status !== 'string' ||
!GOAL_STATUSES.has(value.status as SessionControlGoalStatus) ||
!isInteger(value.turns_used) ||
!isInteger(value.max_turns) ||
!Array.isArray(value.subgoals) ||
!value.subgoals.every(subgoal => typeof subgoal === 'string') ||
!Array.isArray(value.gates) ||
!hasOptionalStrings(value, ['paused_reason', 'last_reason']) ||
(hasOwn(value, 'created_at') && !isFiniteNumber(value.created_at)) ||
(hasOwn(value, 'updated_at') && !isFiniteNumber(value.updated_at)) ||
(hasOwn(value, 'last_verdict') &&
(typeof value.last_verdict !== 'string' ||
!GOAL_VERDICTS.has(value.last_verdict as NonNullable<SessionControlGoal['last_verdict']>)))
) {
return null
}
const contract = parseGoalContract(value.contract)
const parsedGates = value.gates.map(parseGate)
const waitBarrier = hasOwn(value, 'wait_barrier') ? parseWaitBarrier(value.wait_barrier) : undefined
if (!contract || parsedGates.some(gate => gate === null) || (hasOwn(value, 'wait_barrier') && !waitBarrier)) {
return null
}
const gates = parsedGates.filter((gate): gate is SessionControlGate => gate !== null)
const goal: SessionControlGoal = {
contract,
gates,
max_turns: value.max_turns,
status: value.status as SessionControlGoalStatus,
subgoals: [...value.subgoals],
title: value.title,
turns_used: value.turns_used
}
if (hasOwn(value, 'created_at')) {
goal.created_at = value.created_at as number
}
if (hasOwn(value, 'updated_at')) {
goal.updated_at = value.updated_at as number
}
if (hasOwn(value, 'paused_reason')) {
goal.paused_reason = value.paused_reason as string
}
if (hasOwn(value, 'last_reason')) {
goal.last_reason = value.last_reason as string
}
if (hasOwn(value, 'last_verdict')) {
goal.last_verdict = value.last_verdict as SessionControlGoal['last_verdict']
}
if (waitBarrier) {
goal.wait_barrier = waitBarrier
}
return goal
}
function parseLoop(value: unknown): SessionControlLoop | null {
const required = [
'prompt',
'status',
'mode',
'interval_seconds',
'current_delay',
'times',
'until',
'max_ticks',
'ticks_fired',
'created_at',
'last_fired_at',
'next_due_at',
'awaiting_response',
'deferred_by_goal'
]
if (!isRecord(value) || !hasExactFields(value, required, ['paused_reason', 'last_stop_reason'])) {
return null
}
if (
typeof value.prompt !== 'string' ||
typeof value.status !== 'string' ||
!LOOP_STATUSES.has(value.status as SessionControlLoopStatus) ||
typeof value.mode !== 'string' ||
!LOOP_MODES.has(value.mode as SessionControlLoopMode) ||
!isFiniteNumber(value.interval_seconds) ||
!isFiniteNumber(value.current_delay) ||
!isInteger(value.times) ||
typeof value.until !== 'string' ||
!isInteger(value.max_ticks) ||
!isInteger(value.ticks_fired) ||
!isFiniteNumber(value.created_at) ||
!isFiniteNumber(value.last_fired_at) ||
!isFiniteNumber(value.next_due_at) ||
typeof value.awaiting_response !== 'boolean' ||
typeof value.deferred_by_goal !== 'boolean' ||
!hasOptionalStrings(value, ['paused_reason', 'last_stop_reason'])
) {
return null
}
const loop: SessionControlLoop = {
awaiting_response: value.awaiting_response,
created_at: value.created_at,
current_delay: value.current_delay,
deferred_by_goal: value.deferred_by_goal,
interval_seconds: value.interval_seconds,
last_fired_at: value.last_fired_at,
max_ticks: value.max_ticks,
mode: value.mode as SessionControlLoopMode,
next_due_at: value.next_due_at,
prompt: value.prompt,
status: value.status as SessionControlLoopStatus,
ticks_fired: value.ticks_fired,
times: value.times,
until: value.until
}
if (hasOwn(value, 'paused_reason')) {
loop.paused_reason = value.paused_reason as string
}
if (hasOwn(value, 'last_stop_reason')) {
loop.last_stop_reason = value.last_stop_reason as string
}
return loop
}
function parseHeartbeat(value: unknown): SessionControlHeartbeat | null {
const required = ['prompt', 'status', 'interval_seconds', 'created_at', 'last_fired_at', 'fire_count']
if (!isRecord(value) || !hasExactFields(value, required)) {
return null
}
if (
typeof value.prompt !== 'string' ||
typeof value.status !== 'string' ||
!HEARTBEAT_STATUSES.has(value.status as SessionControlHeartbeatStatus) ||
!isInteger(value.interval_seconds) ||
!isFiniteNumber(value.created_at) ||
!isFiniteNumber(value.last_fired_at) ||
!isInteger(value.fire_count)
) {
return null
}
return {
created_at: value.created_at,
fire_count: value.fire_count,
interval_seconds: value.interval_seconds,
last_fired_at: value.last_fired_at,
prompt: value.prompt,
status: value.status as SessionControlHeartbeatStatus
}
}
/** Parses the stable allowlisted backend shape into fresh renderer-owned data. */
export function parseSessionControlSnapshot(value: unknown): SessionControlSnapshot | null {
if (!isRecord(value) || !hasExactFields(value, ['goal', 'loop', 'heartbeat', 'revision', 'updated_at'])) {
return null
}
if (typeof value.revision !== 'string' || !isFiniteNumber(value.updated_at)) {
return null
}
const goal = value.goal === null ? null : parseGoal(value.goal)
const loop = value.loop === null ? null : parseLoop(value.loop)
const heartbeat = value.heartbeat === null ? null : parseHeartbeat(value.heartbeat)
if ((value.goal !== null && !goal) || (value.loop !== null && !loop) || (value.heartbeat !== null && !heartbeat)) {
return null
}
return { goal, heartbeat, loop, revision: value.revision, updated_at: value.updated_at }
}
function parseSessionControlDispatch(value: unknown): SessionControlDispatch | null {
if (!isRecord(value) || !hasExactFields(value, ['type', 'output', 'notice', 'message', 'display'])) {
return null
}
if (
typeof value.type !== 'string' ||
!DISPATCH_TYPES.has(value.type as SessionControlDispatch['type']) ||
![value.output, value.notice, value.message, value.display].every(
field => field === null || typeof field === 'string'
)
) {
return null
}
return {
display: value.display as string | null,
message: value.message as string | null,
notice: value.notice as string | null,
output: value.output as string | null,
type: value.type as SessionControlDispatch['type']
}
}
function emptyEntry(): SessionControlEntry {
return { capability: 'unknown', error: null, loading: false, pendingAction: null, snapshot: null }
}
function currentVersion(sessionId: string): number {
return versions.get(sessionId) ?? 0
}
function advanceVersion(sessionId: string): number {
const next = currentVersion(sessionId) + 1
versions.set(sessionId, next)
return next
}
function currentEventVersion(sessionId: string): number {
return eventVersions.get(sessionId) ?? 0
}
function advanceEventVersion(sessionId: string): number {
const next = currentEventVersion(sessionId) + 1
eventVersions.set(sessionId, next)
return next
}
function isCurrent(sessionId: string, token: number): boolean {
return currentVersion(sessionId) === token
}
function sameEntry(first: SessionControlEntry, second: SessionControlEntry): boolean {
return (
first.capability === second.capability &&
first.error === second.error &&
first.loading === second.loading &&
first.pendingAction === second.pendingAction &&
first.snapshot === second.snapshot
)
}
function publishEntry(sessionId: string, next: SessionControlEntry): SessionControlEntry {
const entries = $sessionControlBySession.get()
const current = entries[sessionId]
if (current && sameEntry(current, next)) {
return current
}
$sessionControlBySession.set({ ...entries, [sessionId]: next })
return next
}
function applyParsedSnapshot(sessionId: string, snapshot: SessionControlSnapshot): SessionControlEntry {
advanceVersion(sessionId)
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
const nextSnapshot = current.snapshot?.revision === snapshot.revision ? current.snapshot : snapshot
return publishEntry(sessionId, {
capability: 'supported',
error: null,
loading: false,
pendingAction: null,
snapshot: nextSnapshot
})
}
/** Applies a valid read/action snapshot and marks its session as supported. */
export function applySessionControlSnapshot(sessionId: string, rawSnapshot: unknown): SessionControlEntry | undefined {
if (!sessionId) {
return undefined
}
const snapshot = parseSessionControlSnapshot(rawSnapshot)
return snapshot ? applyParsedSnapshot(sessionId, snapshot) : undefined
}
/** Applies an event update; invalid updates are deliberately a claimed no-op. */
export function applySessionControlUpdate(sessionId: string, rawSnapshot: unknown): SessionControlEntry | undefined {
if (!sessionId) {
return undefined
}
const snapshot = parseSessionControlSnapshot(rawSnapshot)
if (!snapshot) {
return undefined
}
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
const actionIsPending = current.pendingAction !== null
advanceEventVersion(sessionId)
if (!actionIsPending) {
advanceVersion(sessionId)
}
const nextSnapshot = current.snapshot?.revision === snapshot.revision ? current.snapshot : snapshot
return publishEntry(sessionId, {
...current,
capability: 'supported',
error: null,
loading: actionIsPending ? current.loading : false,
pendingAction: actionIsPending ? current.pendingAction : null,
snapshot: nextSnapshot
})
}
/** Drops one runtime session's entry when the session record is closed/deleted. */
export function clearSessionControl(sessionId: string): void {
if (!sessionId) {
return
}
advanceVersion(sessionId)
const entries = $sessionControlBySession.get()
if (!(sessionId in entries)) {
return
}
const { [sessionId]: _removed, ...remaining } = entries
$sessionControlBySession.set(remaining)
}
/**
* Wipes every entry — the gateway-switch seam. Entries are keyed by runtime
* session id and a different backend mints new ids, so nothing here can be
* reused; in-flight reads/actions from the old backend are invalidated by the
* version bump so a late response cannot repopulate the map.
*/
export function clearAllSessionControl(): void {
for (const sessionId of new Set([...versions.keys(), ...Object.keys($sessionControlBySession.get())])) {
advanceVersion(sessionId)
}
$sessionControlBySession.set({})
versions.clear()
eventVersions.clear()
}
function beginRead(sessionId: string, background: boolean): number {
const token = advanceVersion(sessionId)
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
publishEntry(sessionId, {
...current,
error: null,
loading: background ? current.loading : true
})
return token
}
function beginAction(sessionId: string, action: SessionControlAction): number {
const token = advanceVersion(sessionId)
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
publishEntry(sessionId, {
...current,
error: null,
loading: true,
pendingAction: action
})
return token
}
function boundedError(error: unknown): string {
const message =
error instanceof Error
? error.message
: isRecord(error) && typeof error.message === 'string'
? error.message
: 'Session control request failed'
return message.trim().slice(0, ERROR_LIMIT) || 'Session control request failed'
}
function publishFailure(sessionId: string, token: number, error: unknown, clearPendingAction: boolean): void {
if (!isCurrent(sessionId, token)) {
return
}
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
publishEntry(sessionId, {
...current,
error: boundedError(error),
loading: false,
pendingAction: clearPendingAction ? null : current.pendingAction
})
}
function finishGoneRequest(sessionId: string, token: number, clearPendingAction: boolean): void {
if (!isCurrent(sessionId, token)) {
return
}
markSessionGone(sessionId)
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
publishEntry(sessionId, {
...current,
loading: false,
pendingAction: clearPendingAction ? null : current.pendingAction
})
}
function markUnsupported(sessionId: string, token: number): boolean {
if (!isCurrent(sessionId, token)) {
return false
}
const current = $sessionControlBySession.get()[sessionId] ?? emptyEntry()
if (current.capability === 'unsupported') {
return false
}
advanceVersion(sessionId)
publishEntry(sessionId, {
...current,
capability: 'unsupported',
error: null,
loading: false,
pendingAction: null
})
return true
}
function isMethodNotFound(error: unknown): boolean {
if (isRecord(error) && error.code === -32601) {
return true
}
const message =
error instanceof Error ? error.message : isRecord(error) && typeof error.message === 'string' ? error.message : ''
return message.toLowerCase().includes('method not found') || message.toLowerCase().includes('method-not-found')
}
/** Hydrates one session's structured controls; background refreshes never flash a loading state. */
export async function refreshSessionControl(
sessionId: string,
options: RefreshOptions = {}
): Promise<SessionControlEntry | undefined> {
const existing = $sessionControlBySession.get()[sessionId]
if (!sessionId || existing?.capability === 'unsupported' || isSessionGone(sessionId)) {
return existing
}
const gateway = $gateway.get()
if (!gateway) {
return existing
}
const token = beginRead(sessionId, Boolean(options.background))
try {
const response = await requestForOwnedSession<unknown>(
sessionId,
ambientRequestFor(gateway),
'session.control.read',
{ session_id: sessionId }
)
if (!isCurrent(sessionId, token)) {
return $sessionControlBySession.get()[sessionId]
}
const snapshot = isRecord(response) ? parseSessionControlSnapshot(response.control) : null
if (!snapshot) {
publishFailure(sessionId, token, new Error('Invalid session.control.read response'), false)
return $sessionControlBySession.get()[sessionId]
}
return applyParsedSnapshot(sessionId, snapshot)
} catch (error) {
if (!isCurrent(sessionId, token)) {
return $sessionControlBySession.get()[sessionId]
}
if (isMethodNotFound(error)) {
const transitioned = markUnsupported(sessionId, token)
if (transitioned) {
await refreshSessionGoal(sessionId)
}
return $sessionControlBySession.get()[sessionId]
}
if (isSessionGoneForBackgroundPolling(error)) {
finishGoneRequest(sessionId, token, false)
return $sessionControlBySession.get()[sessionId]
}
publishFailure(sessionId, token, error, false)
return $sessionControlBySession.get()[sessionId]
}
}
/** Runs an allowlisted backend control action; callers own any composer/UI dispatch. */
export async function runSessionControlAction(
sessionId: string,
action: SessionControlAction,
args?: SessionControlActionArgs
): Promise<SessionControlDispatch> {
if (!sessionId) {
throw new Error('A session id is required for session control')
}
if (isSessionGone(sessionId)) {
throw new Error('Session not found')
}
const gateway = $gateway.get()
if (!gateway) {
throw new Error('Session control gateway is unavailable')
}
const eventVersion = currentEventVersion(sessionId)
const token = beginAction(sessionId, action)
try {
const response = await requestForOwnedSession<unknown>(sessionId, ambientRequestFor(gateway), 'session.control', {
action,
args: args ?? {},
session_id: sessionId
})
const snapshot = isRecord(response) ? parseSessionControlSnapshot(response.control) : null
const dispatch = isRecord(response) ? parseSessionControlDispatch(response.dispatch) : null
if (!snapshot || !dispatch) {
const error = new Error('Invalid session.control action response')
publishFailure(sessionId, token, error, true)
throw error
}
if (isCurrent(sessionId, token)) {
applyParsedSnapshot(sessionId, snapshot)
}
return dispatch
} catch (error) {
if (isMethodNotFound(error)) {
const transitioned = eventVersion === currentEventVersion(sessionId) ? markUnsupported(sessionId, token) : false
if (transitioned) {
await refreshSessionGoal(sessionId)
} else {
publishFailure(sessionId, token, error, true)
}
} else if (isSessionGoneForBackgroundPolling(error)) {
finishGoneRequest(sessionId, token, true)
} else {
publishFailure(sessionId, token, error, true)
}
throw error
}
}
/** Refreshes only sessions already proven to support the structured-control RPC. */
export async function refreshSupportedSessionControlAfterTurn(sessionId: string): Promise<void> {
if (!sessionId || $sessionControlBySession.get()[sessionId]?.capability !== 'supported') {
return
}
await refreshSessionControl(sessionId, { background: true })
}
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const LEGACY_VISIBLE_KEY = 'hermes.desktop.statusbarVisible'
const loadStore = () => import('./statusbar-prefs')
describe('statusbar whole-bar visibility', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('shows the bar on a fresh install and for installs that hid it under the v1 key', async () => {
window.localStorage.setItem(LEGACY_VISIBLE_KEY, 'false')
const { $statusbarVisible } = await loadStore()
expect($statusbarVisible.get()).toBe(true)
})
it('still honours a hide made after the update', async () => {
const first = await loadStore()
first.toggleStatusbarVisible()
expect(first.$statusbarVisible.get()).toBe(false)
vi.resetModules()
const reloaded = await loadStore()
expect(reloaded.$statusbarVisible.get()).toBe(false)
})
})
describe('statusbar hidden items', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('surfaces the approval pill for installs that hid it under the v1 defaults, keeping their other choices', async () => {
window.localStorage.setItem(
'hermes.desktop.statusbarHidden',
JSON.stringify(['approval-mode', 'cron', 'gateway-health'])
)
const { $statusbarHiddenIds } = await loadStore()
expect($statusbarHiddenIds.get()).toEqual(['cron', 'gateway-health'])
})
it('still honours hiding the approval pill after the update', async () => {
const first = await loadStore()
first.setStatusbarItemVisible('approval-mode', false)
vi.resetModules()
const reloaded = await loadStore()
expect(reloaded.$statusbarHiddenIds.get()).toContain('approval-mode')
})
})
+35 -9
View File
@@ -1,7 +1,17 @@
import { Codecs, persistentAtom } from '@/lib/persisted'
import { readKey } from '@/lib/storage'
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
// v1 (`hermes.desktop.statusbarHidden`) was seeded with the approval pill
// hidden, so every existing store carries an `approval-mode` the user never
// chose. v2 seeds from v1 minus that id: other customizations survive, the
// pill appears once on update, and hiding it again persists here.
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden.v2'
const LEGACY_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
// v1 (`hermes.desktop.statusbarVisible`) shipped a stretch where the bar was
// opt-in, so many stores hold a `false` the user never chose. v2 is read fresh
// and the v1 key is deliberately NOT seeded from: every existing install comes
// back to "on" once, and hiding it again persists here.
const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible.v2'
// Whole-bar visibility, VS Code's `workbench.statusBar.visible`. On by default.
// Hiding it unmounts the bar (its 15s status poll goes with it), so the way back
@@ -14,14 +24,15 @@ export function toggleStatusbarVisible() {
// 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. The per-turn
// route shortcuts (cron/webhooks/agents) and the terminal toggle are
// navigation, not status, so they start out of the way. The approval pill
// (the yolo zap) stays: whether dangerous commands run unasked is state the
// user should see at a glance. The per-turn
// session readouts (running/session timers, context meter, cache hit rate,
// tokens/sec) 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',
'cache-hit-rate',
'context-usage',
'cron',
@@ -38,12 +49,27 @@ export const STATUSBAR_HIDDEN_BY_DEFAULT: readonly string[] = [
// staying off. An empty array is a real value — the user turned everything on —
// so this uses a sanitizing json codec rather than Codecs.stringArray, which
// drops the key when empty and would resurrect the defaults on next launch.
const sanitizeHiddenIds = (value: unknown): string[] =>
Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
function legacyHiddenSeed(): string[] {
const raw = readKey(LEGACY_HIDDEN_STORAGE_KEY)
if (raw === null) {
return [...STATUSBAR_HIDDEN_BY_DEFAULT]
}
try {
return sanitizeHiddenIds(JSON.parse(raw)).filter(id => id !== 'approval-mode')
} catch {
return [...STATUSBAR_HIDDEN_BY_DEFAULT]
}
}
export const $statusbarHiddenIds = persistentAtom<string[]>(
STATUSBAR_HIDDEN_STORAGE_KEY,
[...STATUSBAR_HIDDEN_BY_DEFAULT],
Codecs.json<string[]>(value =>
Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string' && id.length > 0) : []
)
legacyHiddenSeed(),
Codecs.json<string[]>(sanitizeHiddenIds)
)
export function setStatusbarItemVisible(id: string, visible: boolean) {
+8
View File
@@ -301,6 +301,14 @@ model:
#
# # Data policy: "allow" (default) or "deny" to exclude providers that may store data
# # data_collection: "deny"
#
# # Per-model overrides: same keys, applied only when the agent is on that model
# # (spelling-tolerant match; unset keys fall through to the flat values above).
# # models:
# # "openai/gpt-6-astra":
# # only: ["openai"]
# # "anthropic/claude-fable-5.1":
# # only: ["anthropic"]
# =============================================================================
# OpenRouter Response Caching (only applies when using OpenRouter)
+1
View File
@@ -293,6 +293,7 @@ _TERMINAL_ENV_MAPPINGS = {
"ssh_host", "ssh_user", "ssh_port", "ssh_key", "container_cpu", "container_memory",
"container_disk", "container_persistent", "docker_volumes", "docker_env", "docker_extra_args",
"docker_shm_size", "docker_mount_cwd_to_workspace", "docker_network", "docker_run_as_host_user",
"docker_snap_compat",
"docker_persist_across_processes", "docker_shared_container_key", "docker_orphan_reaper",
"sandbox_dir", "persistent_shell",
)
@@ -0,0 +1,2 @@
Halldrix
# PR #102118 /heartbeat TUI driver

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