Merge origin/main into fix/pkg13-ink-leftovers

Resolves the hermes_cli/cli_tui_mixin.py conflict from #141501 landing: main moved _Panel to
hermes_cli/cli_panel.py and this branch moved the wrap helpers to hermes_cli/cli_tui_wrap.py.
Keep both moves: import _Panel and the wrap helpers, define neither here.

Refs #134889 (N8)
This commit is contained in:
Teknium committed 2026-10-19 12:55:27 -07:00
commit 85188e61c3
199 files changed
+9910 -648

No files matched your search

+5 -3
View File
@@ -112,14 +112,16 @@ runs:
# The key covers the PM code and this action, not only the lock: a
# provisioning change must re-run the cold path on every lane rather
# than restore a store the previous code wrote.
# than restore a store the previous code wrote. The prepare step above
# imports pm and writes pm/__pycache__; that bytecode embeds the source
# mtime of this checkout, so it is excluded or no two jobs share a key.
- name: Cache verified PM tools
id: tools-cache
if: inputs.cache == 'true' && inputs.save-tools-cache == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.prepare.outputs.store }}
key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py') }}-${{ inputs.cache-suffix }}
key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py', '!**/__pycache__/**') }}-${{ inputs.cache-suffix }}
- name: Restore verified PM tools without saving
id: tools-cache-restore
@@ -127,7 +129,7 @@ runs:
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.prepare.outputs.store }}
key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py') }}-${{ inputs.cache-suffix }}
key: setup-pm-tools-v1-${{ steps.prepare.outputs.target }}-${{ inputs.toolchain }}${{ steps.prepare.outputs.extra-packages }}-${{ hashFiles('pm/**', '.github/actions/setup-pm/**', 'scripts/ci/setup_toolchain.py', '!**/__pycache__/**') }}-${{ inputs.cache-suffix }}
- name: Preserve and retrieve the pinned toolchain
if: inputs.archive-inputs == 'true'
+1 -1
View File
@@ -151,7 +151,7 @@ class SlashCommandsMixin:
try:
from model_tools import get_tool_definitions
from types import SimpleNamespace
from agent.memory_manager import inject_memory_provider_tools
from agent.memory_manager_tools import inject_memory_provider_tools
toolsets = _expand_acp_enabled_toolsets(getattr(state.agent, "enabled_toolsets", None))
tools = get_tool_definitions(
+1 -1
View File
@@ -445,7 +445,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
return
try:
from model_tools import get_tool_definitions
from agent.memory_manager import inject_memory_provider_tools
from agent.memory_manager_tools import inject_memory_provider_tools
agent = state.agent
agent.enabled_toolsets = _expand_acp_enabled_toolsets(
+2 -2
View File
@@ -1379,7 +1379,7 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform, memory_manager=None):
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
agent._memory_manager = None
from agent.memory_manager import inject_memory_provider_tools
from agent.memory_manager_tools import inject_memory_provider_tools
inject_memory_provider_tools(agent)
@@ -2132,7 +2132,7 @@ def _inject_context_engine_tools(agent):
_existing_tool_names = {
t.get("function", {}).get("name") for t in agent.tools if isinstance(t, dict)
}
from agent.memory_manager import normalize_tool_schema
from agent.memory_manager_tools import normalize_tool_schema
for _raw_schema in agent.context_compressor.get_tool_schemas():
_schema = normalize_tool_schema(_raw_schema)
if _schema is None:
+53 -2
View File
@@ -1079,9 +1079,60 @@ def _cute_path(p) -> str:
return "." * limit if limit <= 3 else "..." + p[-(limit - 3):]
class _CuteRow(str):
"""A rendered completion row that also keeps its emoji, verb and detail, so the CLI's glyph
layout can rebuild the row without re-parsing the padded ``┊ emoji verb`` string."""
emoji: str
verb: str
detail: str
def __new__(cls, text: str, emoji: str, verb: str, detail: str):
row = super().__new__(cls, text)
row.emoji, row.verb, row.detail = emoji.strip(), verb, detail
return row
def __reduce__(self): # copy/pickle as a plain str; the custom __new__ can't be re-called bare
return (str, (str(self),))
def _cute_row(emoji: str, verb: str, detail: str = "") -> str:
"""``┊ {emoji} {verb:9} {detail}`` -- verb column from ``display.cute.verb.<verb>`` (9 cells wide)."""
return f"┊ {emoji} {t(f'display.cute.verb.{verb}'):9} {detail}"
label = t(f'display.cute.verb.{verb}')
return _CuteRow(f"┊ {emoji} {label:9} {detail}", emoji, label, detail)
def _cute_fallback_row(tool_name: str, args: dict) -> _CuteRow:
"""The row for a tool with no dedicated renderer: its registered (or skin) emoji, its name, and
its preview. One source for both layouts, so their icons can't drift."""
detail = _cute_trunc(build_tool_preview(tool_name, args) or "")
emoji = get_tool_emoji(tool_name)
return _CuteRow(f"┊ {emoji} {tool_name[:9]:9} {detail}", emoji, tool_name, detail)
def get_tool_row_parts(tool_name: str, args: dict | None, result: str | None = None, *, is_error: bool = False):
"""``[(emoji, verb, detail, failure), ...]`` for one completed call: the parts the ``┊`` line
uses, without its padding. ``failure`` is None for a row that succeeded, else that row's
suffix (``" [exit 1]"``, possibly ``""``); batch calls mark the entry that failed."""
try:
args = redact_tool_args_for_display(tool_name, args) or args or {}
is_failure, suffix = _detect_tool_failure(tool_name, result)
call_failure = suffix if is_failure else ("" if is_error else None)
labels = bridge_tool_labels(tool_name, args)
if labels:
suffixes = _bridge_row_suffixes(len(labels), result, suffix if is_failure else "")
marks = [s or None for s in suffixes]
if call_failure is not None and not any(suffixes):
marks[-1] = call_failure # the call failed without naming an entry
return [(lb.emoji, _cute_trunc(lb.text), _cute_trunc(lb.preview) if lb.preview else "", mark)
for lb, mark in zip(labels, marks)]
render = _CUTE_LINES.get(tool_name)
row = render(args, result) if render else None
row = row if isinstance(row, _CuteRow) else _cute_fallback_row(tool_name, args)
return [(row.emoji, row.verb, row.detail, call_failure)]
except Exception:
logger.debug("Tool row parts failed for %s", tool_name, exc_info=True)
return [("⚡", str(tool_name), "", None)]
def _cute_web_extract(a: dict, _r) -> str:
@@ -1257,7 +1308,7 @@ def _get_cute_tool_message(tool_name: str, args: dict, duration: float, result:
render = _CUTE_LINES.get(tool_name)
rows = _cute_bridge_rows(tool_name, args)
if rows is None:
body = render(args, result) if render else f"┊ ⚡ {tool_name[:9]:9} {_cute_trunc(build_tool_preview(tool_name, args) or '')}"
body = render(args, result) if render else _cute_fallback_row(tool_name, args)
rows, suffixes = [body], [failure_suffix if is_failure else ""]
else:
suffixes = _bridge_row_suffixes(len(rows), result, failure_suffix if is_failure else "")
+42 -11
View File
@@ -531,19 +531,46 @@ _REASONING_REQUIRED_MARKERS = (
"always enabled", "cannot be turned off",
)
# Same rejection, wording by *value*: an endpoint that publishes a closed level set refuses the
# thinking-OFF encoding because ``none`` is not in it — "'reasoning_effort' must be one of:
# 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'" (Alibaba token-plan / DashScope
# routes, code ``invalid_parameter_error``). The field is understood and has to stay on the wire;
# only the disabled level is invalid, so the floor step-up (the weakest accepted level) is the
# right recovery. Neither the strip rung nor its "unsupported" wording matches this message, so
# without these markers the title lane fails outright instead of retrying at the floor.
_REASONING_LEVEL_REJECTION_MARKERS = ("must be one of", "invalid value for")
def is_reasoning_required_rejection(error_msg: str) -> bool:
"""Provider 400 saying the model's reasoning cannot be switched OFF ("Reasoning is mandatory for
this endpoint and cannot be disabled", the Nous Portal on gpt-6-astra). The opposite of
``is_reasoning_field_rejection``: the field is understood, the *disable* is refused, so the right
reaction is to step the effort up to the lowest level rather than drop the field (a dropped field
also works, but tells the caller nothing about the next call)."""
def is_reasoning_level_rejection(error_msg: str) -> bool:
"""Provider 400 rejecting the *value* of a reasoning wire control against the endpoint's closed
level set ("'reasoning_effort' must be one of: 'minimal', 'low', …", Alibaba token-plan /
DashScope routes, code ``invalid_parameter_error``). The reasoning-field token must be present
next to the wording — a 400 enum complaint about some other field is not this class. The field is
understood and has to stay on the wire; only the disabled level ("none") is invalid, so the
recovery is the floor step-up in the auxiliary ladder and the disable-drop in the main loop, not
the strip rung and not an abort to the fallback chain."""
msg = (error_msg or "").lower()
token = _REASONING_FIELD_TOKEN.search(msg)
if token is None:
return False
near = msg[max(0, token.start() - 48):token.end() + 96]
return any(m in near for m in _REASONING_REQUIRED_MARKERS)
return any(m in near for m in _REASONING_LEVEL_REJECTION_MARKERS)
def is_reasoning_required_rejection(error_msg: str) -> bool:
"""Provider 400 saying the model's reasoning cannot be switched OFF ("Reasoning is mandatory for
this endpoint and cannot be disabled", the Nous Portal on gpt-6-astra) — or refusing the OFF
*level* because the endpoint's closed set starts at ``minimal``
(``is_reasoning_level_rejection``). The opposite of ``is_reasoning_field_rejection``: the field is
understood, the *disable* is refused, so the right reaction is to step the effort up to the
lowest level rather than drop the field (a dropped field also works, but tells the caller nothing
about the next call)."""
msg = (error_msg or "").lower()
token = _REASONING_FIELD_TOKEN.search(msg)
if token is None:
return False
near = msg[max(0, token.start() - 48):token.end() + 96]
return any(m in near for m in _REASONING_REQUIRED_MARKERS) or is_reasoning_level_rejection(msg)
def is_reasoning_field_rejection(error_msg: str) -> bool:
@@ -1170,10 +1197,14 @@ def _classify_400(c: _Ctx) -> Verdict:
):
return _V_INVALID_ENCRYPTED
# Route rejecting a reasoning disable: a reasoning-mandatory route (GLM-5.3 on Nous Portal /
# OpenRouter) or a chat-only relay that does not accept ``reasoning_effort: none`` at all
# (#114460). Deterministic for the request shape, but the only bad field is the disable — the
# loop drops it and retries once. Must precede request-validation, which would abort as format_error.
if _REASONING_MANDATORY_PATTERN in msg or is_reasoning_field_rejection(msg):
# OpenRouter), a chat-only relay that does not accept ``reasoning_effort: none`` at all
# (#114460), or an endpoint whose closed level set starts above the disable ("must be one of:
# 'minimal', …" — Alibaba token-plan). Deterministic for the request shape, but the only bad
# value is the disable — the loop drops it (or, when the disable is refused rather than the
# field, steps the effort up to the floor) and retries once. Must precede request-validation,
# which would abort as format_error.
if (_REASONING_MANDATORY_PATTERN in msg or is_reasoning_field_rejection(msg)
or is_reasoning_level_rejection(msg)):
return _V_REASONING_MANDATORY
# 400 blaming a field this route never sent (Codex OAuth injects then rejects
# prompt_cache_retention ~20% of the time): transient, retry identical request.
+156 -116
View File
@@ -12,6 +12,7 @@ import json
import logging
import re
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor, wait
from functools import partial
from typing import Any, Callable, Dict, List, Optional
@@ -19,11 +20,14 @@ from typing import Any, Callable, Dict, List, Optional
from agent.redact import redact_for_egress
from agent.memory_caller_scope import bound_scope, caller_scope, claim_instance, in_owner_home, refused_tool_message
# Moved to agent.memory_context; still importable here because agent.memory_manager is a public
# Moved to agent.memory_context / agent.memory_manager_tools; still importable here because agent.memory_manager is a public
# module for plugins (hermes_cli/plugin_validate_private_imports.py).
from agent.memory_context import StreamingContextScrubber, build_memory_context_block, sanitize_context
from agent.memory_manager_tools import (inject_memory_provider_tools, memory_provider_tools_enabled,
memory_provider_tools_exposed, normalize_tool_schema)
from agent.memory_provider import MemoryProvider, PRE_COMPRESS_CHECKPOINT_API_VERSION, ctx_bound, spawn_context_thread
from agent.skill_commands import extract_user_instruction_from_skill_message
from hermes_constants import hermes_home_key
from tools.hook_output_spill import get_spill_config, spill_if_oversized
from tools.registry import tool_error
@@ -36,6 +40,21 @@ _LEGACY_PRE_COMPRESS_API_VERSION = 1
# blocks interpreter exit.
_SYNC_DRAIN_TIMEOUT_S = 5.0
_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0
# A background provider call (sync_turn / queue_prefetch) running longer than this is treated as
# stuck. Well above the shutdown drain bound and the prefetch timeout so a slow-but-healthy backend
# is never counted.
_BACKGROUND_CALL_STALL_S = 30.0
# Admission for external provider calls per (home, provider); see _StuckCallGate. Python cannot stop a
# thread, so every stuck call pins one until it returns. New calls stop once this many calls that started
# after the provider's last successful return are overdue; older orphans belong to an outage it has
# since recovered from.
_MAX_STUCK_CALLS = 4
# While the gate is closed, one call per interval goes through, so a recovered backend proves itself
# without waiting for an orphan to return.
_PROBE_INTERVAL_S = 60.0
# Hard cap on calls in flight per (home, provider), orphans included: failed probes pin one more thread
# each, so admission (probes too) stops here and a backend that stays wedged pins a bounded number.
_MAX_INFLIGHT_CALLS = 16
# -- Signature introspection (providers are duck-typed; call shapes vary) -----
@@ -65,105 +84,6 @@ def _accepts_require_checkpoint(fn: Callable[..., Any]) -> bool:
return _has_var_kwargs(params) or kind in (inspect.Parameter.KEYWORD_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
# -- Tool-schema plumbing -----------------------------------------------------
def normalize_tool_schema(schema: Any) -> Optional[dict[str, Any]]:
"""Return a bare function-tool dict with a resolvable top-level ``name``, else None.
Providers should return ``{"name", "description", "parameters"}`` but some return the
wrapped OpenAI form; wrapping that twice yields a nameless ``function`` and strict
providers (DeepSeek) reject the ENTIRE request, so both shapes are normalized here.
"""
if not isinstance(schema, dict):
return None
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
schema = schema["function"]
name = schema.get("name", "")
return schema if name and isinstance(name, str) else None
def memory_provider_tools_enabled(enabled_toolsets: Optional[list[str]], disabled_toolsets: Optional[list[str]] = None,
*, memory_tool_present: bool = False) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if disabled_toolsets and "memory" in disabled_toolsets:
return False
if memory_tool_present or enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def _tool_name(tool: Any) -> Any:
return tool.get("function", {}).get("name") if isinstance(tool, dict) else None
def memory_provider_tools_exposed(agent: Any) -> bool:
"""Whether external memory-provider tools are exposed on ``agent``.
Same gate as ``inject_memory_provider_tools`` so a provider's ``system_prompt_block()``
never advertises tools absent from the tool surface.
"""
tools = getattr(agent, "tools", None)
present = isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools)
enabled, disabled = getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None)
return memory_provider_tools_enabled(enabled, disabled, memory_tool_present=present)
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface; return count added."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
if not memory_provider_tools_exposed(agent):
# Say so once: a silent 0 leaves the provider looking "half on" with no clue which
# config key (platform_toolsets / disabled_toolsets) gated it.
# See #81014.
_providers = [p for p in getattr(memory_manager, "providers", None) or []
if getattr(p, "name", "") != "builtin"]
if _providers:
logger.info(
"Memory provider(s) %s configured but the 'memory' toolset is "
"gated off for this session (platform_toolsets / "
"agent.disabled_toolsets) — provider tools and system-prompt "
"block are both withheld.",
[getattr(p, "name", type(p).__name__) for p in _providers],
)
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
if getattr(agent, "valid_tool_names", None) is None:
agent.valid_tool_names = set()
existing_tool_names = {_tool_name(tool) for tool in tools if isinstance(tool, dict)}
added = 0
for raw_schema in get_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)", raw_schema,
)
elif schema["name"] not in existing_tool_names:
tools.append({"type": "function", "function": schema})
agent.valid_tool_names.add(schema["name"])
existing_tool_names.add(schema["name"])
added += 1
return added
# -- Provider egress scrubbing -----------------------------------------------
def _scrub(text: str) -> str:
@@ -219,6 +139,60 @@ def _redact_messages_for_egress(messages: Optional[list[Any]]) -> Optional[list[
return None if messages is None else list(_redact_for_provider(messages))
class _StuckCallGate:
"""Process-wide admission for one kind of external provider call, keyed by (Hermes home, provider).
A new MemoryManager is built per agent, so a per-instance view would let every fresh manager start
one more call against a hung backend and pin one more thread. Shared, the gate closes once
_MAX_STUCK_CALLS calls that started after the key's last successful return are past their deadline,
admits one half-open probe per _PROBE_INTERVAL_S while closed, and never lets more than
_MAX_INFLIGHT_CALLS be in flight. Calls not yet overdue are not counted, so healthy concurrent
sessions run side by side. Each call removes only its own entry.
"""
def __init__(self) -> None:
self.lock = threading.Lock()
self.calls: dict[tuple[str, str], dict[object, tuple[float, float]]] = {} # token -> (start, deadline)
self.last_ok: dict[tuple[str, str], float] = {}
self.probe_at: dict[tuple[str, str], float] = {} # interval start: gate close, then each probe
self.skips: dict[tuple[str, str], int] = {}
def enter(self, key: tuple[str, str], token: object, timeout: float) -> Optional[tuple[int, int, int]]:
"""Register ``token`` and return None, or ``(stuck, in_flight, skips)`` when the call is skipped."""
now = time.monotonic()
with self.lock:
calls = self.calls.setdefault(key, {})
last_ok = self.last_ok.get(key, float("-inf"))
stuck = sum(start > last_ok and now > deadline for start, deadline in calls.values())
if len(calls) >= _MAX_INFLIGHT_CALLS:
admit = False
elif stuck < _MAX_STUCK_CALLS:
admit = True
self.probe_at.pop(key, None)
self.skips.pop(key, None) # gate open: the next outage warns on its first skip
else:
admit = now - self.probe_at.setdefault(key, now) >= _PROBE_INTERVAL_S
if admit:
self.probe_at[key] = now
if not admit:
skips = self.skips[key] = self.skips.get(key, 0) + 1
return stuck, len(calls), skips
calls[token] = (now, now + timeout)
return None
def leave(self, key: tuple[str, str], token: object, *, ok: bool) -> None:
with self.lock:
calls = self.calls.get(key)
if calls is None or calls.pop(token, None) is None:
return
if calls:
if ok:
self.last_ok[key] = time.monotonic()
return
for state in (self.calls, self.last_ok, self.probe_at, self.skips):
state.pop(key, None)
class MemoryManager:
"""Builtin provider (always first) plus at most one external provider.
@@ -226,6 +200,12 @@ class MemoryManager:
swallows per-provider exceptions.
"""
# External prefetch threads (#134392) and background sync_turn / queue_prefetch calls. The home is
# part of the key because one gateway process can serve several profiles, each with its own config
# and credentials for a same-named provider: one profile's hung backend must not silence another's.
_PREFETCH_GATE = _StuckCallGate()
_BACKGROUND_GATE = _StuckCallGate()
def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None:
self._providers: list[MemoryProvider] = []
self._tool_to_provider: dict[str, MemoryProvider] = {}
@@ -236,8 +216,8 @@ class MemoryManager:
if timeout <= 0:
raise ValueError("external_prefetch_timeout must be positive")
self._external_prefetch_timeout = timeout
self._external_prefetch_threads: dict[str, threading.Thread] = {}
self._external_prefetch_lock = threading.Lock()
# Captured at build time, inside the profile scope the agent is built for.
self._home_key = hermes_home_key()
# Single-worker background executor for end-of-turn sync/prefetch, created lazily so
# the builtin-only path spawns no threads; one worker serializes a provider's writes.
self._sync_executor: Optional[ThreadPoolExecutor] = None
@@ -246,6 +226,7 @@ class MemoryManager:
# within a bound, then report exactly what it abandoned.
self._background_futures: dict[Future, str] = {}
self._shutting_down = False
self._teardown_claim: Optional[str] = None # see claim_teardown()
self._shutdown_drain_state: dict[str, Any] = {
"status": "not_started", "abandoned_writes": 0, "abandoned_prefetches": 0, "active_tasks": 0,
}
@@ -375,38 +356,44 @@ class MemoryManager:
def _prefetch_provider(self, provider: MemoryProvider, query: str, *, session_id: str = "") -> str:
"""Run one provider's prefetch; external providers are bounded by a timeout. A stuck external
call keeps running on its daemon thread and the provider is skipped on later turns until it returns."""
call keeps running on its daemon thread; _PREFETCH_GATE decides whether later turns call again."""
if provider.name == "builtin":
return provider.prefetch(query, session_id=session_id)
result_box: dict[str, Any] = {}
key = self._registry_key(provider)
def _run() -> None:
try:
result_box["value"] = provider.prefetch(query, session_id=session_id) or ""
except Exception as exc: # pragma: no cover - re-raised by caller
result_box["error"] = exc
finally:
self._PREFETCH_GATE.leave(key, thread, ok="error" not in result_box)
thread = spawn_context_thread(_run, name=f"memory-prefetch-{provider.name}")
with self._external_prefetch_lock:
existing = self._external_prefetch_threads.get(provider.name)
if existing is not None and existing.is_alive():
logger.debug("Memory provider '%s' prefetch is still running; skipping this turn", provider.name)
return ""
self._external_prefetch_threads[provider.name] = thread
skipped = self._PREFETCH_GATE.enter(key, thread, self._external_prefetch_timeout)
if skipped:
logger.debug("Memory provider '%s' prefetch skipped (%d stuck, %d in flight)",
provider.name, *skipped[:2])
return ""
try:
thread.start()
except RuntimeError:
# Never started, so its finally never runs: drop the entry or it would count forever.
self._PREFETCH_GATE.leave(key, thread, ok=False)
raise
thread.join(self._external_prefetch_timeout)
if thread.is_alive():
logger.warning(
"Memory provider '%s' prefetch timed out after %.1fs; skipping it until "
"the stuck call returns", provider.name, self._external_prefetch_timeout,
"Memory provider '%s' prefetch timed out after %.1fs; the call keeps running in the "
"background (later turns may skip it while the provider stays stuck)", provider.name,
self._external_prefetch_timeout,
)
return ""
with self._external_prefetch_lock:
if self._external_prefetch_threads.get(provider.name) is thread:
self._external_prefetch_threads.pop(provider.name, None)
if "error" in result_box:
raise result_box["error"]
result = result_box.get("value", "")
@@ -440,7 +427,8 @@ class MemoryManager:
return
clean_query = _redact_for_provider(clean_query)
self._submit_background(lambda: self._each_provider(
"queue_prefetch failed (non-fatal)", lambda p: p.queue_prefetch(clean_query, session_id=session_id),
"queue_prefetch failed (non-fatal)",
lambda p: self._call_unless_stalled(p, lambda: p.queue_prefetch(clean_query, session_id=session_id)),
providers=providers,
), kind="prefetch")
@@ -476,7 +464,8 @@ class MemoryManager:
for keyword, value in optional_kwargs.items():
if value is not None and self._provider_sync_accepts(provider, keyword):
kwargs[keyword] = value
provider.sync_turn(clean_user_content, assistant_content, **kwargs)
self._call_unless_stalled(
provider, lambda: provider.sync_turn(clean_user_content, assistant_content, **kwargs))
self._submit_background(
lambda: self._each_provider("sync_turn failed", _sync, level=logging.WARNING, providers=providers)
@@ -521,6 +510,37 @@ class MemoryManager:
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
def _call_unless_stalled(self, provider: MemoryProvider, call: Callable[[], Any]) -> None:
"""Run a background provider call unless _BACKGROUND_GATE skips it (the provider has stuck calls);
skipping costs that turn's write/prefetch but stops a wedged backend from pinning one more
thread per fresh manager. The builtin provider is never gated."""
if provider.name == "builtin":
call()
return
key = self._registry_key(provider)
token = object()
skipped = self._BACKGROUND_GATE.enter(key, token, _BACKGROUND_CALL_STALL_S)
if skipped:
stuck, in_flight, skips = skipped
if skips == 1 or skips % 100 == 0:
logger.warning(
"Memory provider '%s' has %d background calls stuck for over %.0fs (%d in flight); "
"skipping its sync/prefetch until it recovers (%d skipped)", provider.name, stuck,
_BACKGROUND_CALL_STALL_S, in_flight, skips,
)
return
ok = False
try:
call()
ok = True
finally:
self._BACKGROUND_GATE.leave(key, token, ok=ok)
def _registry_key(self, provider: MemoryProvider) -> tuple[str, str]:
"""Process-wide registry key: the home the providers were bound to (``initialize_all``, which may
name another profile than the one this manager was built in), else the build-time home."""
return ((self._bound_scope or {}).get("home_key") or self._home_key, provider.name)
def _forget_background_future(self, future: Future) -> None:
with self._sync_executor_lock:
self._background_futures.pop(future, None)
@@ -815,6 +835,26 @@ class MemoryManager:
lambda p: p.on_delegation(task, result, child_session_id=child_session_id, **kwargs),
)
def claim_teardown(self, *, session_end: bool) -> bool:
"""Claim extraction + ``shutdown_all()`` for end-of-session shutdown or soft release. The first
claimer wins and the loser skips, so the two never overlap and providers (no idempotency
contract) are never shut down twice. Session-end callers may re-claim (the gateway claims before
its flush, then ``shutdown_memory_provider`` claims again); a soft release is once-only and also
loses to a manager another owner is already draining."""
with self._sync_executor_lock:
if self._teardown_claim is None and (session_end or not self._shutting_down):
self._teardown_claim = "session_end" if session_end else "soft_release"
return True
return session_end and self._teardown_claim == "session_end"
def release_soft(self) -> None:
"""Soft release (cache eviction, CLI agent rebuild): stop provider writer threads, which otherwise leak
one per evicted agent, WITHOUT on_session_end(): sweeps commit first and a pending session end owns
both. Flush first, since shutdown_all()'s short drain would cancel writes the old worker finished."""
if self.claim_teardown(session_end=False):
self.flush_pending(timeout=10)
self.shutdown_all()
def shutdown_all(self) -> None:
"""Drain the background executor (bounded), then shut providers down in reverse order."""
self._drain_sync_executor()
+106
View File
@@ -0,0 +1,106 @@
"""Memory-provider tool-schema plumbing: the gate that decides whether external memory-provider
tools are exposed on an agent's tool surface, and the injection of their schemas."""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
def normalize_tool_schema(schema: Any) -> Optional[dict[str, Any]]:
"""Return a bare function-tool dict with a resolvable top-level ``name``, else None.
Providers should return ``{"name", "description", "parameters"}`` but some return the
wrapped OpenAI form; wrapping that twice yields a nameless ``function`` and strict
providers (DeepSeek) reject the ENTIRE request, so both shapes are normalized here.
"""
if not isinstance(schema, dict):
return None
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
schema = schema["function"]
name = schema.get("name", "")
return schema if name and isinstance(name, str) else None
def memory_provider_tools_enabled(enabled_toolsets: Optional[list[str]], disabled_toolsets: Optional[list[str]] = None,
*, memory_tool_present: bool = False) -> bool:
"""Return whether external memory-provider tools should be exposed."""
if disabled_toolsets and "memory" in disabled_toolsets:
return False
if memory_tool_present or enabled_toolsets is None:
return True
if not enabled_toolsets:
return False
if "memory" in enabled_toolsets:
return True
try:
from toolsets import resolve_toolset
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
except Exception:
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
return False
def _tool_name(tool: Any) -> Any:
return tool.get("function", {}).get("name") if isinstance(tool, dict) else None
def memory_provider_tools_exposed(agent: Any) -> bool:
"""Whether external memory-provider tools are exposed on ``agent``.
Same gate as ``inject_memory_provider_tools`` so a provider's ``system_prompt_block()``
never advertises tools absent from the tool surface.
"""
tools = getattr(agent, "tools", None)
present = isinstance(tools, (list, tuple)) and any(_tool_name(t) == "memory" for t in tools)
enabled, disabled = getattr(agent, "enabled_toolsets", None), getattr(agent, "disabled_toolsets", None)
return memory_provider_tools_enabled(enabled, disabled, memory_tool_present=present)
def inject_memory_provider_tools(agent: Any) -> int:
"""Append external memory-provider tool schemas to an agent tool surface; return count added."""
memory_manager = getattr(agent, "_memory_manager", None)
tools = getattr(agent, "tools", None)
if not memory_manager or tools is None:
return 0
if not memory_provider_tools_exposed(agent):
# Say so once: a silent 0 leaves the provider looking "half on" with no clue which
# config key (platform_toolsets / disabled_toolsets) gated it.
# See #81014.
_providers = [p for p in getattr(memory_manager, "providers", None) or []
if getattr(p, "name", "") != "builtin"]
if _providers:
logger.info(
"Memory provider(s) %s configured but the 'memory' toolset is "
"gated off for this session (platform_toolsets / "
"agent.disabled_toolsets) — provider tools and system-prompt "
"block are both withheld.",
[getattr(p, "name", type(p).__name__) for p in _providers],
)
return 0
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
if not callable(get_schemas):
return 0
if getattr(agent, "valid_tool_names", None) is None:
agent.valid_tool_names = set()
existing_tool_names = {_tool_name(tool) for tool in tools if isinstance(tool, dict)}
added = 0
for raw_schema in get_schemas():
schema = normalize_tool_schema(raw_schema)
if schema is None:
logger.warning(
"Memory provider returned a tool schema with no resolvable "
"name; skipping to avoid poisoning the request (%r)", raw_schema,
)
elif schema["name"] not in existing_tool_names:
tools.append({"type": "function", "function": schema})
agent.valid_tool_names.add(schema["name"])
existing_tool_names.add(schema["name"])
added += 1
return added
+1 -1
View File
@@ -534,7 +534,7 @@ def _memory_parts(agent: Any) -> list[str]:
# configuration has already gated off (#81014).
if agent._memory_manager:
try:
from agent.memory_manager import memory_provider_tools_exposed as _mem_exposed
from agent.memory_manager_tools import memory_provider_tools_exposed as _mem_exposed
except Exception:
_mem_exposed = None
if _mem_exposed is None or _mem_exposed(agent):
@@ -0,0 +1,66 @@
import fs from 'node:fs/promises'
import net from 'node:net'
import path from 'node:path'
import { expect, test } from 'vitest'
import type { InstallStamp } from './install-stamp'
import { closeGatewayTicketBridges, createGatewayTicketResolver } from './local-gateway-python'
import { shortSocketTmpDir } from './local-gateway.test-helpers'
import { bundledPayload, payloadTicketRuntime } from './payload-backend'
// #138906 (RT-21): the Store/MSIX bundled backend is `kind: 'python'` but its command is the
// payload's CLI shim (bin/hermes.exe), which rejects the ticket helper's `-P -u -c`. This runs
// the real resolver and helper process against a payload laid out the way the builder stamps it.
test.skipIf(process.platform === 'win32')('a bundled payload mints tickets with its interpreter, not the CLI shim', async () => {
const home = await shortSocketTmpDir('gw-bundled-')
const resources = await shortSocketTmpDir('gw-bundled-res-')
const repo = path.resolve('../..')
const python = process.env.HERMES_TEST_PYTHON || 'python3'
const root = path.join(resources, 'agent-payload')
await fs.mkdir(path.join(root, 'bin'), { recursive: true })
await fs.mkdir(path.join(root, 'tools', 'python', 'bin'), { recursive: true })
await fs.mkdir(path.join(root, 'venv', 'site-packages'), { recursive: true })
await fs.symlink(repo, path.join(root, 'app'))
// The CLI shim: like hermes.exe, it is not an interpreter and refuses interpreter flags.
await fs.writeFile(path.join(root, 'bin', 'hermes'), '#!/bin/sh\necho "hermes: unrecognized arguments: $1" >&2\nexit 2\n', { mode: 0o755 })
const { execFileSync } = await import('node:child_process')
const realPython = execFileSync(python, ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim()
await fs.symlink(realPython, path.join(root, 'tools', 'python', 'bin', 'python3'))
const stamp = {
schemaVersion: 1, commit: 'a'.repeat(40), commitDate: null, branch: null, builtAt: null, dirty: false,
source: 'ci', distribution: 'desktop-app', updateMechanism: 'microsoft-store', baseVersion: null,
displayVersion: null, distance: null, payload: 'bundled', tag: 'v1.0.0',
runtime: { repoDir: 'app', toolsDir: 'tools', storePython: 'tools/python/bin/python3',
sitePackages: 'venv/site-packages', commands: { hermes: 'bin/hermes' } }
} as unknown as InstallStamp
const payload = bundledPayload(resources, stamp)!
const endpoint = { profile_id: home, instance_id: 'owner', runtime_protocol: 1, control_home: null }
// The shape resolveHermesBackend() returns for a bundled payload (main.ts).
const backend = {
kind: 'python', command: payload.shim, shell: false, root: payload.repoDir,
env: { PYTHONPATH: '', PYTHONHOME: '', HERMES_RUNTIME_DIR: payload.toolsDir }, ...payloadTicketRuntime(payload)
}
const server = net.createServer(socket => socket.once('data', () => {
socket.end(JSON.stringify({ protocol: 1, id: 1, ok: true, result: { ...endpoint, ticket: 'bundled-grant' } }) + '\n')
}))
await new Promise<void>(resolve => server.listen(path.join(home, 'gateway.sock'), resolve))
await fs.chmod(path.join(home, 'gateway.sock'), 0o600)
await fs.chmod(home, 0o700)
try {
const mint = createGatewayTicketResolver(async () => backend, () => home)
const ticket = await mint(endpoint, 'native-http')
expect(ticket).toBe('bundled-grant')
} finally {
closeGatewayTicketBridges()
await new Promise<void>(resolve => server.close(() => resolve()))
await fs.rm(home, { recursive: true, force: true })
await fs.rm(resources, { recursive: true, force: true })
}
}, 30_000)
@@ -0,0 +1,57 @@
import type * as ChildProcessAPI from 'node:child_process'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, expect, test, vi } from 'vitest'
const spawned = vi.hoisted(() => [] as { command: string; args: string[]; options: any }[])
vi.mock('node:child_process', async importOriginal => {
const actual = await importOriginal<typeof ChildProcessAPI>()
return { ...actual, spawn: (command: string, args: string[], options: any) => {
spawned.push({ command, args, options })
const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), kill: () => true })
setImmediate(() => {
child.stdout.end(JSON.stringify([process.execPath, '-I', '-c', 'bootstrap']))
child.emit('close', 0)
})
return child
} }
})
const { ticketRuntimeFor } = await import('./local-gateway-python')
const realPlatform = process.platform
afterEach(() => {
Object.defineProperty(process, 'platform', { value: realPlatform })
spawned.length = 0
})
// RT-10 (#138774): a HERMES_DESKTOP_HERMES=...\hermes.cmd override is a shell backend. With
// shell:true Node hands cmd.exe one unquoted line, which a profile path with a space splits at
// `C:\Users\John` (#74064). The runtime-command probe must quote the launcher like every other
// shell spawn (windowsShellCommand), and keep the probe flag a separate argument.
test('a .cmd override in a path with spaces asks its launcher for the runtime as one quoted token', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const command = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\bin\\hermes.cmd'
const root = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\hermes-agent'
await expect(ticketRuntimeFor({ command, kind: 'command', shell: true, root }))
.resolves.toEqual({ command: process.execPath, env: { PYTHONPATH: root } })
expect(spawned).toHaveLength(1)
expect(spawned[0].command).toBe(`"${command}"`)
expect(spawned[0].args).toEqual(['--print-runtime-command'])
expect(spawned[0].options).toMatchObject({ shell: true, cwd: root, windowsHide: true })
})
test('a launcher spawned without a shell keeps its bare path', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const command = 'C:\\Users\\John Doe\\AppData\\Local\\hermes\\bin\\hermes.exe'
await ticketRuntimeFor({ command, kind: 'command', shell: false, root: 'C:\\r' })
expect(spawned[0].command).toBe(command)
expect(spawned[0].options).toMatchObject({ shell: false })
})
@@ -24,6 +24,22 @@ test('a Python backend is the helper interpreter as-is', async () => {
await expect(ticketRuntimeFor(backend)).resolves.toBe(backend)
})
test('a bundled launcher keeps its CLI command but names the payload Python for ticket minting', async () => {
const backend = {
command: '/resources/agent-payload/bin/hermes.exe',
kind: 'python',
shell: false,
env: { HERMES_RUNTIME_DIR: '/resources/agent-payload/tools' },
ticketCommand: '/resources/agent-payload/tools/python/python.exe',
ticketEnv: { PYTHONPATH: '/resources/agent-payload/repo;/resources/agent-payload/venv/Lib/site-packages' }
}
await expect(ticketRuntimeFor(backend)).resolves.toEqual({
command: backend.ticketCommand,
env: { ...backend.env, ...backend.ticketEnv }
})
})
test.skipIf(process.platform === 'win32')('an installed launcher backend mints with the interpreter it reports', async () => {
// The normal Windows install resolves to kind 'command' (the installation launcher).
const python = process.execPath
+11 -2
View File
@@ -3,7 +3,7 @@ import { statSync } from 'node:fs'
import path from 'node:path'
import type { GatewayTicketPurpose } from './local-gateway'
import { hiddenWindowsChildOptions } from './windows-child-options'
import { hiddenWindowsChildOptions, windowsShellCommand } from './windows-child-options'
interface TicketEndpoint {
profile_id: string
@@ -164,6 +164,9 @@ interface ResolvedTicketBackend {
shell?: boolean
env?: NodeJS.ProcessEnv
root?: string
/** A bundled CLI still launches normally, but ticket minting needs its interpreter. */
ticketCommand?: string
ticketEnv?: NodeJS.ProcessEnv
}
const RUNTIME_COMMAND_TIMEOUT_MS = 30_000
@@ -173,7 +176,9 @@ function launcherRuntimePython(backend: ResolvedTicketBackend & { root: string }
return new Promise((resolve, reject) => {
const fail = (detail: string) => reject(new Error(`Gateway ticket bootstrap could not resolve the installed Hermes Python runtime: ${detail}`))
const child = spawn(backend.command, ['--print-runtime-command'], hiddenWindowsChildOptions({
// A .cmd override is a shell backend: quote it as the backend spawn does, or cmd.exe
// splits a launcher path with a space at the first one (#74064).
const child = spawn(windowsShellCommand(backend.command, Boolean(backend.shell)), ['--print-runtime-command'], hiddenWindowsChildOptions({
cwd: backend.root, env: { ...process.env, ...backend.env }, shell: Boolean(backend.shell), stdio: ['ignore', 'pipe', 'ignore']
}))
@@ -222,6 +227,10 @@ function isFile(candidate: string): boolean {
* PYTHONPATH is its whole import closure (-P still keeps the cwd out).
*/
export async function ticketRuntimeFor(backend: ResolvedTicketBackend): Promise<{ command: string; env?: NodeJS.ProcessEnv }> {
if (backend.ticketCommand) {
return { command: backend.ticketCommand, env: { ...backend.env, ...backend.ticketEnv } }
}
if (backend.kind === 'python' && !backend.shell) {
return backend
}
+11 -16
View File
@@ -417,7 +417,14 @@ import {
import { wireOauthSessionResponse } from './oauth-session-response'
import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap'
import { createParentStartMarkerResolver } from './parent-process-identity'
import { bundledPayload, installIdForRoot, type PayloadInfo, payloadPythonPath } from './payload-backend'
import {
bundledPayload,
installIdForRoot,
type PayloadInfo,
payloadPythonPath,
payloadTicketRuntime,
requireBundledPayload
} from './payload-backend'
import { petOverlayClickThrough, shouldPopInOnOverlayClosed } from './pet-overlay'
import { placePetOverlay, registerPetOverlayIpc } from './pet-overlay-ipc'
import {
@@ -3674,21 +3681,6 @@ function createNativePackagedStrategy(
return new ExternalStrategy(INSTALL_STAMP)
}
/**
* The bundled Python payload for a strategy whose update check runs inside
* it. A stamp that names such a mechanism without a payload is a broken
* install; say so instead of failing on `payload.storePython`.
*/
function requireBundledPayload(mechanism: UpdaterStrategy['mechanism']): PayloadInfo {
const payload: PayloadInfo | null = bundledPayload(process.resourcesPath)
if (!payload) {
throw new Error(`${mechanism} updates require the bundled application payload, which this install has none of`)
}
return payload
}
/**
* The checkout updater strategy (windows-handoff / posix-handoff). The flow
* lives in updater/checkout.ts; this is the composition root that hands it
@@ -5026,6 +5018,7 @@ async function resolveHermesBackend(backendArgs: string[]): Promise<ResolvedHerm
command: payload.shim,
args: [...backendArgs],
env: { ...buildDesktopBackendEnv(), HERMES_RUNTIME_DIR: payload.toolsDir },
...payloadTicketRuntime(payload),
root: payload.repoDir,
bootstrap: false,
shell: false,
@@ -5209,6 +5202,8 @@ interface ResolvedHermesBackend {
shell: boolean
local?: string
root?: string
ticketCommand?: string
ticketEnv?: NodeJS.ProcessEnv
installStamp?: Readonly<InstallStamp> | null
activeRoot?: string
isPackaged?: boolean
+16 -1
View File
@@ -6,7 +6,7 @@ import path from 'node:path'
import { test, vi } from 'vitest'
import type { InstallStamp, PayloadRuntime } from './install-stamp'
import { bundledPayload, installIdForRoot, payloadPythonPath } from './payload-backend'
import { bundledPayload, installIdForRoot, payloadPythonPath, payloadTicketRuntime } from './payload-backend'
function stamp(runtime: PayloadRuntime, payload: InstallStamp['payload'] = 'bundled'): InstallStamp {
return {
@@ -66,6 +66,21 @@ test('bundled launch paths come from build metadata without filesystem access',
}
})
test('ticket minting runs the payload python, never the CLI shim', () => {
const payload = {
storePython: '/p/tools/python',
repoDir: '/p/repo',
sitePackages: '/p/venv/site-packages',
shim: '/p/bin/hermes'
}
const runtime = payloadTicketRuntime(payload)
assert.equal(runtime.ticketCommand, payload.storePython)
assert.notEqual(runtime.ticketCommand, payload.shim)
assert.deepEqual(runtime.ticketEnv, { PYTHONPATH: payloadPythonPath(payload) })
})
// ─── update channel helpers ─────────────────────────────────────────
test('installIdForRoot matches the Python install id (sha16 of the canonical path)', () => {
+27
View File
@@ -46,6 +46,33 @@ export function payloadPythonPath(payload: Pick<PayloadInfo, 'repoDir' | 'sitePa
return [payload.repoDir, payload.sitePackages].join(path.delimiter)
}
/**
* The bundled shim is the Hermes CLI, not an interpreter, so it rejects the
* ticket bridge's `-P -u -c`. Ticket minting runs the payload python against
* the same import closure the shim gives the CLI.
*/
export function payloadTicketRuntime(payload: Pick<PayloadInfo, 'storePython' | 'repoDir' | 'sitePackages'>): {
ticketCommand: string
ticketEnv: NodeJS.ProcessEnv
} {
return { ticketCommand: payload.storePython, ticketEnv: { PYTHONPATH: payloadPythonPath(payload) } }
}
/**
* The bundled Python payload for a strategy whose update check runs inside
* it. A stamp that names such a mechanism without a payload is a broken
* install; say so instead of failing on `payload.storePython`.
*/
export function requireBundledPayload(mechanism: string, resourcesPath = process.resourcesPath): PayloadInfo {
const payload: PayloadInfo | null = bundledPayload(resourcesPath)
if (!payload) {
throw new Error(`${mechanism} updates require the bundled application payload, which this install has none of`)
}
return payload
}
// ─── update channel ─────────────────────────────────────────────────────────
//
// The CLI owns the channel records; Electron only reads the install id for
+45 -5
View File
@@ -246,12 +246,16 @@ export class CanonicalDesktopProtocol {
// failure carries no reason; its outcome is unknown and the token stays for the exact retry.
if (typeof reason === 'string') { this.settleModelConfirmation(params) }
// Any typed refusal of a rewind wrote nothing. A derived /undo or /retry is retained by operation
// alone, so keeping the refused request would resend it for every later /undo instead of deriving.
if (typeof reason === 'string' && params.operation === 'rewind') { this.forgetMutation(params) }
// A typed busy refusal of a rewind or a move wrote nothing either; the retry is a new request at the new revision.
if (reason !== 'revision_conflict' && reason !== 'stale_generation' && !(reason === 'session_busy' && ['rewind', 'workspace'].includes(params.operation as string))) { return }
// A confirmed fence refusal did not mutate. Ambiguous transport failures keep
// the original revision/id so a retry cannot overwrite another user's edit.
for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } }
this.forgetMutation(params)
// The owner names its current fences on the refusal: adopt them so the next attempt presents them.
const data = (error as { data?: { revision?: unknown; execution_generation?: unknown } })?.data
const owner = canonicalSessionKey(params.session_id, params.profile)
@@ -289,9 +293,13 @@ export class CanonicalDesktopProtocol {
return POLICY_EDIT_METHODS.has(method) && typeof prepared.operation === 'string' ? 'session.mutate' : method
}
private retainedMutation(sessionId: unknown, profile: unknown, operation: string, payload: Record<string, unknown>, withGeneration: boolean): Record<string, unknown> {
// `derived`: the payload was derived from a snapshot (a typed /undo or /retry names the Nth newest user
// turn). Keyed by operation alone, as Ink's mutateCanonical and the classic PreparedMutations do: a
// retry after a lost reply re-presents the original target, never one re-derived from a transcript the
// first attempt already rewound.
private retainedMutation(sessionId: unknown, profile: unknown, operation: string, payload: Record<string, unknown>, withGeneration: boolean, derived = false): Record<string, unknown> {
const owner = canonicalSessionKey(sessionId, profile)
const key = JSON.stringify([owner, operation, payload])
const key = JSON.stringify([owner, operation, derived ? null : payload])
this.retireMutations(owner, key)
const retained = this.mutations.get(key)
@@ -319,6 +327,38 @@ export class CanonicalDesktopProtocol {
return method !== 'session.create' && params.profile !== undefined ? { ...prepared, profile: params.profile } : prepared
}
private fencedMutation(method: string, params: Record<string, unknown>, fenced: { operation: string; payload: Record<string, unknown> }): Record<string, unknown> {
// An edit / regenerate / restore names its target explicitly; a typed /undo or /retry derives it.
const derived = method === 'session.rewind' && params.derived_target === true
// A replay-only probe only ever re-presents the retained rewind (localAnswer handles the rest).
if (derived && params.replay_only === true && !this.retainsDerivedRewind(params.session_id, params.profile)) {
throw new Error('No retained rewind to replay')
}
return this.retainedMutation(params.session_id, params.profile, fenced.operation, fenced.payload, true, derived)
}
// A typed /undo or /retry whose reply was lost is still retained for its exact retry.
private retainsDerivedRewind(sessionId: unknown, profile: unknown): boolean {
return this.mutations.has(JSON.stringify([canonicalSessionKey(sessionId, profile), 'rewind', null]))
}
// A request this protocol answers without the owner, or undefined. A replay-only /undo or /retry
// probe with no retained rewind owes nothing: no request is sent, and the command derives its target.
localAnswer(method: string, params: Record<string, unknown>): Record<string, unknown> | undefined {
if (method === 'session.rewind' && params.replay_only === true && !this.retainsDerivedRewind(params.session_id, params.profile)) {
return { session_id: params.session_id, status: 'nothing', rewound_count: 0 }
}
return undefined
}
// Drop the retained request the owner answered: its outcome is known, so no retry may resend it.
private forgetMutation(params: Record<string, unknown>) {
for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } }
}
// Only a session's very next control may retry an ambiguous one: any other verb retires the rest.
private retireMutations(owner: string, keep?: string) {
for (const key of [...this.mutations.keys()]) { if (key !== keep && JSON.parse(key)[0] === owner) { this.mutations.delete(key) } }
@@ -379,7 +419,7 @@ export class CanonicalDesktopProtocol {
'session.rewind': () => ({ operation: 'rewind', payload: { target_message_id: params.target_message_id } })
} as Record<string, () => { operation: string; payload: Record<string, unknown> }>)[method]?.()
if (fenced) { return this.retainedMutation(params.session_id, params.profile, fenced.operation, fenced.payload, true) }
if (fenced) { return this.fencedMutation(method, params, fenced) }
if (method === 'session.model') {
const payload = { ...(params.payload as Record<string, unknown>), ...(typeof params.confirm === 'string' ? { confirm: params.confirm } : {}) }
@@ -625,7 +665,7 @@ export class CanonicalDesktopProtocol {
private mutationReceipt(method: string, params: Record<string, unknown>, value: any): any {
if (value.session_id !== params.session_id) { throw new Error('Metadata receipt destination mismatch') }
for (const [key, mutation] of this.mutations) { if (mutation.request_id === params.request_id) { this.mutations.delete(key) } }
this.forgetMutation(params)
this.settleModelConfirmation(params)
this.retireSupersededMutations(params, value.revision)
@@ -0,0 +1,105 @@
import { expect, test } from 'vitest'
import { CanonicalDesktopProtocol } from './canonical-protocol'
// A typed /undo or /retry derives its target from a fresh snapshot. After a lost reply the owner has
// already rewound, so the repeat's snapshot names the turn before: the retained rewind, not a new one at
// that target, must be what the repeat sends (as Ink's mutateCanonical and the classic CLI do).
function primed() {
const protocol = new CanonicalDesktopProtocol()
protocol.result('session.resume', { session_id: 's' }, { session_id: 's', revision: 7, execution_generation: 3 })
return protocol
}
const derived = (target: number) => ({ session_id: 's', target_message_id: target, derived_target: true })
test('a derived rewind whose reply was lost is re-sent for the repeat, not re-derived', () => {
const protocol = primed()
const lost = protocol.prepare('session.rewind', derived(5))
protocol.failure(lost, new Error('Hermes gateway connection lost'))
// The repeat's snapshot already lacks row 5 and names row 3; the original request goes out again.
protocol.result('session.resume', { session_id: 's' }, { session_id: 's', revision: 8, execution_generation: 4 })
const repeat = protocol.prepare('session.rewind', derived(3))
expect(repeat).toEqual(lost)
expect(repeat).toMatchObject({ payload: { target_message_id: 5 }, expected_revision: 7, expected_generation: 3 })
})
test('an answered derived rewind is not replayed for the next /undo', () => {
const protocol = primed()
const first = protocol.prepare('session.rewind', derived(5))
protocol.result('session.rewind', first, {
session_id: 's',
revision: 8,
rewound_count: 2,
target_message: { id: 5 }
})
const next = protocol.prepare('session.rewind', derived(3))
expect(next.request_id).not.toBe(first.request_id)
expect(next).toMatchObject({ payload: { target_message_id: 3 }, expected_revision: 8 })
})
test('a typed refusal of a derived rewind lets the repeat derive again', () => {
const protocol = primed()
const refused = protocol.prepare('session.rewind', derived(5))
protocol.failure(refused, { data: { reason: 'revision_conflict', revision: 9 } })
const retry = protocol.prepare('session.rewind', derived(5))
expect(retry.request_id).not.toBe(refused.request_id)
expect(retry).toMatchObject({ payload: { target_message_id: 5 }, expected_revision: 9 })
})
test('another control retires a lost derived rewind', () => {
const protocol = primed()
const lost = protocol.prepare('session.rewind', derived(5))
protocol.failure(lost, new Error('socket closed'))
protocol.prepare('session.title', { session_id: 's', title: 'renamed' })
const later = protocol.prepare('session.rewind', derived(3))
expect(later.request_id).not.toBe(lost.request_id)
expect(later).toMatchObject({ payload: { target_message_id: 3 } })
})
test('an explicit rewind (edit, regenerate, restore) stays keyed by its own target', () => {
const protocol = primed()
const lost = protocol.prepare('session.rewind', { session_id: 's', target_message_id: 5 })
protocol.failure(lost, new Error('socket closed'))
expect(protocol.prepare('session.rewind', { session_id: 's', target_message_id: 5 })).toEqual(lost)
const other = protocol.prepare('session.rewind', { session_id: 's', target_message_id: 3 })
expect(other.request_id).not.toBe(lost.request_id)
expect(other).toMatchObject({ payload: { target_message_id: 3 } })
})
test('an owner refusal that names a reason lets the next /undo derive again', () => {
const protocol = primed()
const refused = protocol.prepare('session.rewind', derived(5))
protocol.failure(refused, { data: { reason: 'invalid_params', field: 'target_message_id' } })
const next = protocol.prepare('session.rewind', derived(3))
expect(next.request_id).not.toBe(refused.request_id)
expect(next).toMatchObject({ payload: { target_message_id: 3 }, expected_revision: 7 })
})
test('a replay-only probe re-presents only a retained derived rewind', () => {
const protocol = primed()
const probe = { session_id: 's', derived_target: true, replay_only: true }
expect(protocol.localAnswer('session.rewind', probe)).toMatchObject({ status: 'nothing', rewound_count: 0 })
expect(() => protocol.prepare('session.rewind', probe)).toThrow('No retained rewind')
const lost = protocol.prepare('session.rewind', derived(5))
protocol.failure(lost, new Error('socket closed'))
expect(protocol.localAnswer('session.rewind', probe)).toBeUndefined()
expect(protocol.prepare('session.rewind', probe)).toEqual(lost)
})
@@ -0,0 +1,67 @@
// @vitest-environment node
import { expect, test } from 'vitest'
import { HermesGateway } from './client'
// Review (#141492, RT-40): on a named launch profile the owner names itself in the snapshot's
// `info.profile_name`, and a redial forgets that route. A repeated /undo or /retry after a lost
// rewind reply must still find the retained rewind (keyed under the named profile) and re-present
// it: the replay-only probe is answered locally only after the attach has re-learned the owner.
test('a named-profile owner re-presents a lost rewind after a redial', async () => {
const wsPackage = 'ws'
const { WebSocketServer } = await import(wsPackage)
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 })
await new Promise<void>(resolve => server.once('listening', resolve))
const mutations: any[] = []
let revision = 4
server.on('connection', (peer: any) => {
peer.on('message', (bytes: Buffer) => {
const { id, method, params } = JSON.parse(bytes.toString())
if (method === 'session.mutate') {
mutations.push(params)
// The first rewind commits on the owner, and its reply is lost with the socket.
if (mutations.length === 1) {
revision += 1
return peer.terminate()
}
}
const result = method === 'session.mutate'
? { session_id: 's', revision, operation: 'rewind', rewound_count: 2, target_message: { id: 14, content: 'second question' } }
: { session_id: 's', stored_session_id: 's', revision, execution_generation: 2, info: { profile_name: 'work' }, messages: [], prompts: [] }
peer.send(JSON.stringify({ jsonrpc: '2.0', id, result }))
})
})
const client = new HermesGateway()
const url = `ws://127.0.0.1:${(server.address() as { port: number }).port}/api/ws?native_dial=fixture&ticket=one-use`
try {
await client.connect(url)
await client.request('session.resume', { session_id: 's' })
const closed = new Promise<void>(resolve => client.onState(state => { if (state === 'closed') { resolve() } }))
await expect(client.request('session.rewind', { session_id: 's', target_message_id: 14, derived_target: true }))
.rejects.toThrow()
await closed
await client.connect(url)
const replayed = await client.request<{ rewound_count?: number }>('session.rewind', { session_id: 's', derived_target: true, replay_only: true })
expect(mutations).toHaveLength(2)
expect(mutations[1]).toEqual(mutations[0])
expect(mutations[1]).toMatchObject({ profile: 'work', operation: 'rewind', payload: { target_message_id: 14 } })
expect(replayed.rewound_count).toBe(2)
} finally {
client.close()
for (const peer of server.clients) { peer.terminate() }
await new Promise<void>(resolve => server.close(() => resolve()))
}
})
+6
View File
@@ -414,6 +414,12 @@ export class HermesGateway extends JsonRpcGatewayClient {
// implicit route. Its revision and generation are cached under that owner.
params = this.routeCanonicalParams(method, params)
// Answered locally only once the owner's route is known: a named launch profile's
// retained rewind is keyed under its name, which a redial forgets until the attach.
const local = this.protocol.localAnswer(method, params)
if (local) { return local as T }
let prepared = this.protocol.prepare(method, params)
const wireMethod = this.protocol.wire(method, prepared)
@@ -39,6 +39,7 @@ import {
sessionTileOwnerRoute
} from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { flushStreamDeltas } from '@/store/stream-delta-flush'
import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
@@ -48,7 +49,6 @@ import type { GatewayRequester } from '../contrib/types'
import { uploadComposerAttachment } from '../session/hooks/use-prompt-actions'
import { isStaleTargetError } from '../session/hooks/use-prompt-actions'
import {
appendMidTurnUserMessage,
applyBranchVisibility,
applyReloadOptimistic,
applyRewindOptimistic,
@@ -61,6 +61,7 @@ import {
rebindSurvivorRowIds,
revalidateEditPlan,
runRewindSubmit,
sealAcceptedCorrection,
type SurvivorUserRowIds
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
@@ -477,20 +478,8 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored
if (result?.status === 'redirected') {
if (mode === 'interrupt') {
mutate(state => {
const message = state.messages.find(candidate => candidate.id === messageId)
// A newer reply may precede this ACK; it already owns its boundary.
const hasNewReply = state.messages.slice(state.messages.findIndex(candidate => candidate.id === messageId) + 1)
.some(candidate => candidate.role === 'assistant')
return message && state.streamId && !hasNewReply
? appendMidTurnUserMessage(
{ ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) },
message
)
: state
})
flushStreamDeltas(sessionId)
mutate(state => sealAcceptedCorrection(state, messageId))
}
triggerHaptic('submit')
@@ -499,7 +488,12 @@ export function useSessionTileActions({ requestGateway, runtimeId, scope, stored
}
if (result?.status === 'queued') {
if (mode === 'interrupt') {
// A redirect queued in the build window is the next turn; an
// accepted steer joins this one after its running tool.
if (mode === 'steer') {
flushStreamDeltas(sessionId)
mutate(state => sealAcceptedCorrection(state, messageId, { keepRunningTools: true }))
} else {
moveOptimisticMessageToEnd()
}
@@ -12,6 +12,7 @@ import {
chatMessageText,
completeOpenTimelineParts,
type GatewayEventPayload,
hasUnresolvedToolCall,
mergeFinalAssistantText,
normalizeWs,
partsText,
@@ -33,6 +34,7 @@ import type { ScopedServerRequest } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { isDiskFullErrorMessage, notifyError } from '@/store/notifications'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { registerStreamDeltaFlush } from '@/store/stream-delta-flush'
import { upsertSubagent } from '@/store/subagents'
import { $todosBySession, setSessionTodos } from '@/store/todos'
import { broadcastTranscriptChanged } from '@/store/transcript-sync'
@@ -205,15 +207,23 @@ export function useMessageStream({
}
]
} else {
nextMessages = prev.map(m =>
m.id === streamId
? {
...m,
parts: transform(m.parts, m),
pending: patchesSealedBubble ? (m.pending ?? false) : opts.pending ? opts.pending(m) : true
}
: m
)
nextMessages = prev.map(m => {
if (m.id !== streamId) {
return m
}
const patched = { ...m, parts: transform(m.parts, m) }
// A bubble a steer sealed stays pending only while its own tools run.
return {
...patched,
pending: patchesSealedBubble
? Boolean(m.pending) && hasUnresolvedToolCall(patched)
: opts.pending
? opts.pending(m)
: true
}
})
}
if (patchesSealedBubble) {
@@ -473,6 +483,9 @@ export function useMessageStream({
[flushQueuedDeltas]
)
// A steer seals the live bubble from outside this hook; it flushes our buffer first.
useEffect(() => registerStreamDeltaFlush(sessionId => flushQueuedDeltas(sessionId)), [flushQueuedDeltas])
// Page Visibility does not report every Windows/Linux focus transition.
// Flush queued deltas on both signals so returning to a chat cannot leave a
// completed chunk waiting for the next throttled timer.
@@ -1037,6 +1050,11 @@ export function useMessageStream({
// degraded websocket leaves its tool row spinning forever. The turn is
// provably done here — nothing can still be running — so seal any
// tool-call parts that never saw their completion event.
// A bubble a steer sealed while its tool ran stays pending for that tool's
// result; the turn is over now, so a lost tool.complete must not keep it spinning.
nextMessages = nextMessages.map(message =>
message.pending && message.interim ? { ...message, pending: false } : message
)
nextMessages = bindReceiptUserRows(sealOpenToolParts(nextMessages), persistedTurn)
const hasInlineError = nextMessages.some(
@@ -37,7 +37,7 @@ import { useMessageStream } from './index'
const SID = 'steer-order-session'
let handleEvent: ((event: GatewayEvent) => void) | null = null
let redirect: ((text: string) => Promise<boolean>) | null = null
let redirect: ((text: string, mode?: 'interrupt' | 'steer') => Promise<boolean>) | null = null
let states: Map<string, ClientSessionState>
/** Stub only the RPC boundary; both action handlers and streaming reducers are real. */
@@ -278,6 +278,64 @@ describe('steer mid-turn keeps arrival order (user bubble never above prior outp
])
})
it.each([false, true])('an accepted steer marks its arrival: the tool keeps running above, the answer streams below (tile=%s)', async tile => {
await mountHarness(tile)
emit({ payload: {}, session_id: SID, type: 'message.start' })
emit({ payload: { text: 'checking first' }, session_id: SID, type: 'message.delta' })
await flushDeltas()
emit({ payload: { args: { command: 'sleep 5' }, name: 'terminal', tool_id: 't1' }, session_id: SID, type: 'tool.start' })
requestGatewayMock.mockImplementationOnce(async () => ({ status: 'queued' }))
await act(async () => {
await expect(redirect!('use the other file', 'steer')).resolves.toBe(true)
})
expect(requestGatewayMock).toHaveBeenLastCalledWith('session.steer', { session_id: SID, text: 'use the other file' })
// The steer interrupts nothing: the tool it waits on still reads as running.
const sealed = states.get(SID)!.messages[0]
expect(sealed.parts.find(part => part.type === 'tool-call')).not.toHaveProperty('completedAt')
expect(sealed.pending).toBe(true)
emit({ payload: { name: 'terminal', result: 'ok', tool_id: 't1' }, session_id: SID, type: 'tool.complete' })
expect(states.get(SID)!.messages[0].pending).toBe(false)
emit({ payload: { text: 'switched to the other file' }, session_id: SID, type: 'message.delta' })
await flushDeltas()
emit({ payload: { text: 'switched to the other file' }, session_id: SID, type: 'message.complete' })
expect(transcript()).toEqual([
'assistant:checking first',
'user:use the other file',
'assistant:switched to the other file'
])
})
it.each([false, true])('an accepted steer paints text still buffered above it, and a lost tool.complete cannot outlive the turn (tile=%s)', async tile => {
await mountHarness(tile)
emit({ payload: {}, session_id: SID, type: 'message.start' })
emit({ payload: { text: 'Hello' }, session_id: SID, type: 'message.delta' })
await flushDeltas()
emit({ payload: { args: { command: 'sleep 5' }, name: 'terminal', tool_id: 't1' }, session_id: SID, type: 'tool.start' })
// Buffered, not yet painted: the delta timer has not fired.
emit({ payload: { text: ' world' }, session_id: SID, type: 'message.delta' })
requestGatewayMock.mockImplementationOnce(async () => ({ status: 'queued' }))
await act(async () => {
await expect(redirect!('change course', 'steer')).resolves.toBe(true)
})
// tool.complete for t1 is lost; the answer to the steer completes the turn.
emit({ payload: { text: 'new direction' }, session_id: SID, type: 'message.delta' })
await flushDeltas()
emit({ payload: { text: 'new direction' }, session_id: SID, type: 'message.complete' })
expect(transcript()).toEqual(['assistant:Hello world', 'user:change course', 'assistant:new direction'])
expect(states.get(SID)!.messages.every(message => !message.pending)).toBe(true)
expect(states.get(SID)!.messages[0].parts.find(part => part.type === 'tool-call')).toHaveProperty('completedAt')
})
it.each([false, true])('a refused redirect preserves the continuing stream (tile=%s)', async tile => {
await mountHarness(tile)
@@ -41,6 +41,7 @@ import {
setTurnStartedAt
} from '@/store/session'
import { $sessionStates, isLiveTurnAwaitingEvents, isSessionRemote } from '@/store/session-states'
import { flushStreamDeltas } from '@/store/stream-delta-flush'
import { clearSessionSubagents } from '@/store/subagents'
import { runGatewayRestart } from '@/store/system-actions'
import { clearSessionTodos } from '@/store/todos'
@@ -73,6 +74,7 @@ import {
rebindSurvivorRowIds,
retryKeepsDeepConfirm,
runRewindSubmit,
sealAcceptedCorrection,
type SurvivorUserRowIds
} from './rewind'
import { useSlashCommand } from './slash'
@@ -898,21 +900,8 @@ export function usePromptActions({
if (result?.status === 'redirected') {
if (mode === 'interrupt') {
updateSessionState(id, state => {
const message = state.messages.find(candidate => candidate.id === messageId)
// A newer reply may precede this ACK; it already owns its boundary.
const hasNewReply = state.messages
.slice(state.messages.findIndex(candidate => candidate.id === messageId) + 1)
.some(candidate => candidate.role === 'assistant')
return message && state.streamId && !hasNewReply
? appendMidTurnUserMessage(
{ ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) },
message
)
: state
})
flushStreamDeltas(id)
updateSessionState(id, state => sealAcceptedCorrection(state, messageId))
}
triggerHaptic('submit')
@@ -921,9 +910,12 @@ export function usePromptActions({
}
if (result?.status === 'queued') {
// Build-window redirects become the next turn, not part of the
// active reply, so retain the optimistic row at the tail.
if (mode === 'interrupt') {
// A redirect queued in the build window is the next turn; an
// accepted steer joins this one after its running tool.
if (mode === 'steer') {
flushStreamDeltas(id)
updateSessionState(id, state => sealAcceptedCorrection(state, messageId, { keepRunningTools: true }))
} else {
moveOptimisticMessageToEnd()
}
@@ -19,6 +19,7 @@ import {
type ChatMessagePart,
chatMessageText,
finalizeInterruptedMessages,
hasUnresolvedToolCall,
textPart
} from '@/lib/chat-messages'
import type { ConfirmRequest } from '@/store/confirm'
@@ -460,32 +461,77 @@ export function finalizeStoppedMessages(
* user row, which made the post-correction reply a sibling of the sealed
* partial and dropped the partial and the correction from view (#119015).
*/
export function appendMidTurnUserMessage<
State extends {
interimBoundaryPending: boolean
messages: ChatMessage[]
pendingBranchGroup?: null | string
streamId: null | string
}
>(state: State, message: ChatMessage): State {
const liveId = state.streamId
const sealed = finalizeUserInterruptedMessages(state.messages, liveId)
const sealedLiveKept = liveId !== null && sealed.some(row => row.id === liveId)
export function appendMidTurnUserMessage<State extends LiveStreamState>(state: State, message: ChatMessage): State {
const sealed = sealLiveStream(state)
const messages = [
...(sealedLiveKept ? sealed.map(row => (row.id === liveId ? { ...row, interim: true } : row)) : sealed),
message
]
return { ...sealed, messages: [...sealed.messages, message] }
}
interface LiveStreamState {
interimBoundaryPending: boolean
messages: ChatMessage[]
pendingBranchGroup?: null | string
streamId: null | string
}
/**
* The seal half of `appendMidTurnUserMessage`, for a mid-turn arrival that is
* rendered separately (a typed /steer's notice row).
*
* A steer interrupts nothing (`keepRunningTools`): the tool it waits on keeps
* its live row in the sealed bubble, where its result still lands by call id.
*/
export function sealLiveStream<State extends LiveStreamState>(state: State, { keepRunningTools = false } = {}): State {
const liveId = state.streamId
const sealed = keepRunningTools
? sealKeepingRunningTools(state.messages, liveId)
: finalizeUserInterruptedMessages(state.messages, liveId)
const sealedLiveKept = liveId !== null && sealed.some(row => row.id === liveId)
return {
...state,
messages,
messages: sealedLiveKept ? sealed.map(row => (row.id === liveId ? { ...row, interim: true } : row)) : sealed,
...(state.pendingBranchGroup ? { pendingBranchGroup: null } : {}),
streamId: null,
interimBoundaryPending: state.interimBoundaryPending || sealedLiveKept
}
}
/** `finalizeInterruptedMessages` minus the seal on tool calls still waiting for a result:
* their bubble stays pending (a tool row only renders running inside a running message)
* until the result lands by call id, or the turn's completion settles it. */
function sealKeepingRunningTools(messages: ChatMessage[], streamId: null | string): ChatMessage[] {
return messages.flatMap(message =>
(message.pending || message.id === streamId) && hasUnresolvedToolCall(message)
? [{ ...message, interim: true }]
: finalizeInterruptedMessages([message], streamId)
)
}
/** An accepted correction's reserved bubble becomes the arrival boundary: the
* output already on screen stays above it, everything after streams below. */
export function sealAcceptedCorrection<State extends LiveStreamState>(
state: State,
messageId: string | undefined,
{ keepRunningTools = false } = {}
): State {
const index = state.messages.findIndex(candidate => candidate.id === messageId)
// A newer reply may precede this ACK; it already owns its boundary.
if (index < 0 || !state.streamId || state.messages.slice(index + 1).some(row => row.role === 'assistant')) {
return state
}
const sealed = sealLiveStream(
{ ...state, messages: state.messages.filter(candidate => candidate.id !== messageId) },
{ keepRunningTools }
)
return { ...sealed, messages: [...sealed.messages, state.messages[index]] }
}
// ---------------------------------------------------------------------------
// Reload (regenerate)
// ---------------------------------------------------------------------------
@@ -1,7 +1,10 @@
import { JsonRpcGatewayError } from '@hermes/shared'
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CanonicalDesktopProtocol } from '@/api/canonical-protocol'
import { en } from '@/i18n/en'
import { textPart } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { resolveDesktopCommand } from '@/lib/desktop-slash-commands'
import { $composerDraft, setComposerDraft } from '@/store/composer'
@@ -11,6 +14,7 @@ import { dropSessionState, publishSessionState } from '@/store/session-states'
import { actRender, Harness, type HarnessHandle, RUNTIME_SESSION_ID } from './index-test-utils'
import { clearSingleFlightSessionResumeState } from './single-flight-resume'
import { truncateRewoundMessages } from './slash-session-commands'
// /undo, /retry, /steer and /queue run against the session owner's canonical verbs (the Ink TUI's
// behaviour on the same gateway), never as `slash.exec`, which the shared gateway refuses.
@@ -45,8 +49,15 @@ const SNAPSHOT = {
type Responder = (method: string, params?: Record<string, unknown>) => unknown
// A canonical client answers the replay-only rewind probe locally while nothing is retained
// (CanonicalDesktopProtocol.localAnswer); these single-shot peers retain nothing.
function gateway(respond: Responder) {
return vi.fn(async (method: string, params?: Record<string, unknown>) => (respond(method, params) ?? {}) as never)
return vi.fn(
async (method: string, params?: Record<string, unknown>) =>
(method === 'session.rewind' && params?.replay_only
? { status: 'nothing', rewound_count: 0 }
: (respond(method, params) ?? {})) as never
)
}
async function mount(requestGateway: ReturnType<typeof gateway>) {
@@ -58,8 +69,12 @@ async function mount(requestGateway: ReturnType<typeof gateway>) {
return handle!
}
// The replay-only probe each /undo and /retry sends first (answered locally while nothing is
// retained) is asserted on its own; these lists hold the requests that name a target.
const callsOf = (requestGateway: ReturnType<typeof gateway>, method: string) =>
requestGateway.mock.calls.filter(([name]) => name === method).map(([, params]) => params)
requestGateway.mock.calls
.filter(([name, params]) => name === method && !(params as { replay_only?: unknown } | undefined)?.replay_only)
.map(([, params]) => params)
const renderedText = (handle: HarnessHandle) =>
handle
@@ -74,6 +89,15 @@ const queuedTexts = () =>
const canonical = () => $connection.set({ mode: 'local', wsUrl: 'ws://localhost/api/ws?native_dial=unminted' } as never)
// The shared owner's typed refusal of a rewind while a turn runs, as the wire delivers it.
const BUSY = new JsonRpcGatewayError('session_busy', { code: 4001, data: { reason: 'session_busy' } })
// The same refusal while another writer holds the compression lock: Stop does not end it.
const COMPRESSING = new JsonRpcGatewayError('session_busy', {
code: 4001,
data: { field: 'compression', reason: 'session_busy' }
})
beforeEach(() => {
clearSingleFlightSessionResumeState()
window.localStorage.removeItem('hermes.desktop.composerQueue.v1')
@@ -122,9 +146,12 @@ describe('/undo on a canonical session', () => {
const handle = await mount(requestGateway)
await handle.submitText('/undo')
expect(callsOf(requestGateway, 'session.rewind')).toEqual([
{ session_id: RUNTIME_SESSION_ID, target_message_id: 14 }
])
expect(requestGateway.mock.calls.filter(([name]) => name === 'session.rewind').map(([, params]) => params)).toEqual(
[
{ session_id: RUNTIME_SESSION_ID, derived_target: true, replay_only: true },
{ session_id: RUNTIME_SESSION_ID, target_message_id: 14, derived_target: true }
]
)
expect(callsOf(requestGateway, 'slash.exec')).toEqual([])
expect($composerDraft.get()).toBe('second question')
expect(renderedText(handle)).toContain(copy.undidOther('2'))
@@ -145,7 +172,7 @@ describe('/undo on a canonical session', () => {
await handle.submitText('/undo 2')
expect(callsOf(requestGateway, 'session.rewind')).toEqual([
{ session_id: RUNTIME_SESSION_ID, target_message_id: 11 }
{ session_id: RUNTIME_SESSION_ID, target_message_id: 11, derived_target: true }
])
})
@@ -158,6 +185,31 @@ describe('/undo on a canonical session', () => {
expect(callsOf(requestGateway, 'session.rewind')).toEqual([])
expect(renderedText(handle)).toContain(copy.undoNothing)
})
it('refuses during a running turn with main’s stop-first guidance, leaving the composer alone', async () => {
const requestGateway = gateway(method => {
if (method === 'session.resume') {
return SNAPSHOT
}
if (method === 'session.rewind') {
throw BUSY
}
})
publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true })
setComposerDraft('half-typed follow-up')
const handle = await mount(requestGateway)
await handle.submitText('/undo')
expect(renderedText(handle)).toContain('Stop the current reply first')
expect(renderedText(handle)).toContain('then run /undo')
expect(renderedText(handle)).not.toContain('error: session_busy')
expect($composerDraft.get()).toBe('half-typed follow-up')
expect(callsOf(requestGateway, 'session.interrupt')).toEqual([])
expect(callsOf(requestGateway, 'prompt.submit')).toEqual([])
expect(queuedTexts()).toEqual([])
})
})
describe('/retry on a canonical session', () => {
@@ -178,7 +230,7 @@ describe('/retry on a canonical session', () => {
await handle.submitText('/retry')
expect(callsOf(requestGateway, 'session.rewind')).toEqual([
{ session_id: RUNTIME_SESSION_ID, target_message_id: 14 }
{ session_id: RUNTIME_SESSION_ID, target_message_id: 14, derived_target: true }
])
expect(callsOf(requestGateway, 'prompt.submit')).toEqual([
expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'second question' })
@@ -200,6 +252,349 @@ describe('/retry on a canonical session', () => {
expect(callsOf(requestGateway, 'prompt.submit')).toEqual([])
expect(renderedText(handle)).toContain(copy.retryUnsupported)
})
it('refuses during a running turn with main’s stop-first guidance and resubmits nothing', async () => {
const requestGateway = gateway(method => {
if (method === 'session.resume') {
return SNAPSHOT
}
if (method === 'session.rewind') {
throw BUSY
}
})
publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true })
const handle = await mount(requestGateway)
await handle.submitText('/retry')
expect(renderedText(handle)).toContain('Stop the current reply first')
expect(renderedText(handle)).toContain('then run /retry')
expect(callsOf(requestGateway, 'prompt.submit')).toEqual([])
expect(callsOf(requestGateway, 'session.interrupt')).toEqual([])
expect(queuedTexts()).toEqual([])
})
})
describe('/undo and /retry while a compression holds the session', () => {
beforeEach(canonical)
for (const command of ['undo', 'retry'] as const) {
it(`/${command} says to wait for the compression instead of pressing Stop`, async () => {
const requestGateway = gateway(method => {
if (method === 'session.resume') {
return SNAPSHOT
}
if (method === 'session.rewind') {
throw COMPRESSING
}
})
const handle = await mount(requestGateway)
await handle.submitText(`/${command}`)
expect(renderedText(handle)).toContain(`a /compress is running; run /${command} when it finishes`)
expect(renderedText(handle)).not.toContain('Stop the current reply first')
expect(callsOf(requestGateway, 'session.interrupt')).toEqual([])
expect(callsOf(requestGateway, 'prompt.submit')).toEqual([])
})
}
it('keeps the wait text for a busy refusal only, not another refusal naming the same field', async () => {
const other = new JsonRpcGatewayError('invalid_params', {
code: 4001,
data: { field: 'compression', reason: 'invalid_params' }
})
const requestGateway = gateway(method => {
if (method === 'session.resume') {
return SNAPSHOT
}
if (method === 'session.rewind') {
throw other
}
})
const handle = await mount(requestGateway)
await handle.submitText('/undo')
expect(renderedText(handle)).toContain('error: invalid_params')
expect(renderedText(handle)).not.toContain('a /compress is running')
})
})
// The real CanonicalDesktopProtocol in front of a revision-fenced owner that answers an exact retry with
// its original receipt (the authority's durable receipt by request_id). The first rewind commits and its
// reply is lost; the user repeats the command.
const THREE_EXCHANGES: { content: unknown; role: string }[] = [
{ role: 'user', content: 'first question' },
{ role: 'assistant', content: 'first answer' },
{ role: 'user', content: 'second question' },
{ role: 'assistant', content: 'second answer' },
{ role: 'user', content: 'third question' },
{ role: 'assistant', content: 'third answer' }
]
function lostRewindReply(transcript = THREE_EXCHANGES) {
const protocol = new CanonicalDesktopProtocol()
const rows = transcript.map((row, index) => ({ ...row, row_id: 11 + index, active: true }))
const receipts = new Map<unknown, Record<string, unknown>>()
let revision = 4
let loseReply = true
const owner = (wire: string, params: Record<string, unknown>) => {
if (wire === 'session.resume') {
const messages = rows.filter(row => row.active).map(({ active: _active, ...row }) => row)
return { session_id: RUNTIME_SESSION_ID, revision, execution_generation: 2, messages }
}
const replay = receipts.get(params.request_id)
if (replay) {
return replay
}
if (params.expected_revision !== revision) {
throw Object.assign(new Error('revision_conflict'), { data: { reason: 'revision_conflict', revision } })
}
const target = (params.payload as { target_message_id: number }).target_message_id
const rewound = rows.filter(row => row.active && row.row_id >= target)
rewound.forEach(row => (row.active = false))
revision += 1
const receipt = {
session_id: RUNTIME_SESSION_ID,
revision,
rewound_count: rewound.length,
target_message: { id: target, content: rewound[0].content }
}
receipts.set(params.request_id, receipt)
if (loseReply) {
loseReply = false
throw new Error('Hermes gateway connection lost')
}
return receipt
}
const requestGateway = vi.fn(async (method: string, params: Record<string, unknown> = {}) => {
if (method !== 'session.resume' && method !== 'session.rewind') {
return {} as never
}
const local = protocol.localAnswer(method, params)
if (local) {
return local as never
}
const prepared = protocol.prepare(method, params)
try {
return protocol.result(method, prepared, owner(protocol.wire(method, prepared), prepared)) as never
} catch (error) {
protocol.failure(prepared, error)
throw error
}
})
return { requestGateway, active: () => rows.filter(row => row.active).map(row => row.content) }
}
/** Rendered bubbles bound to the owner's durable rows (a hydrated or refreshed transcript). */
const boundMessages = (texts: string[]) =>
texts.map((text, index) => ({
id: `m${index}`,
parts: [textPart(text)],
role: index % 2 ? ('assistant' as const) : ('user' as const),
rowId: 11 + index,
timestamp: index
}))
async function mountWith(requestGateway: ReturnType<typeof gateway>, seedMessages: unknown[]) {
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={seedMessages}
/>
)
return handle!
}
describe('a repeat after a lost rewind reply on a canonical session', () => {
beforeEach(canonical)
it('/undo removes only the newest exchange and restores its text', async () => {
const owner = lostRewindReply()
let handle: HarnessHandle | null = null
const seedMessages = [
'first question',
'first answer',
'second question',
'second answer',
'third question',
'third answer'
].map((text, index) => ({
id: `m${index}`,
parts: [textPart(text)],
role: index % 2 ? ('assistant' as const) : ('user' as const),
rowId: 11 + index,
timestamp: index
}))
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={owner.requestGateway}
seedMessages={seedMessages}
/>
)
await handle!.submitText('/undo')
expect(renderedText(handle!)).toContain('Hermes gateway connection lost')
await handle!.submitText('/undo')
expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer'])
expect($composerDraft.get()).toBe('third question')
expect(renderedText(handle!)).toContain(copy.undidOther('2'))
// The view drops the rewound rows the owner named, not the turn this repeat's snapshot pointed at.
expect(renderedText(handle!)).toContain('second answer')
expect(renderedText(handle!)).not.toContain('third question')
})
it('/retry resends the newest question, not the one before it', async () => {
const owner = lostRewindReply()
const handle = await mount(owner.requestGateway)
await handle.submitText('/retry')
await handle.submitText('/retry')
expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer'])
expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([
expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'third question' })
])
})
// The repeat re-presents the retained rewind before deriving (or checking) a target of its own:
// the rewound transcript may hold no turn the command would accept.
it('/retry that removed the only user turn resends it', async () => {
const owner = lostRewindReply(THREE_EXCHANGES.slice(0, 2))
const handle = await mount(owner.requestGateway)
await handle.submitText('/retry')
await handle.submitText('/retry')
expect(renderedText(handle)).not.toContain(copy.retryNothing)
expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([
expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'first question' })
])
})
it('/undo N restores the removed question when fewer than N turns remain', async () => {
const owner = lostRewindReply()
const handle = await mount(owner.requestGateway)
await handle.submitText('/undo 2')
await handle.submitText('/undo 2')
expect(owner.active()).toEqual(['first question', 'first answer'])
expect($composerDraft.get()).toBe('second question')
expect(renderedText(handle)).toContain(copy.undidOther('4'))
})
it('a text-only /retry is not blocked by an older media turn', async () => {
const media = [{ type: 'image_url', image_url: 'x' }]
const owner = lostRewindReply([{ role: 'user', content: media }, ...THREE_EXCHANGES.slice(1, 4)])
const handle = await mount(owner.requestGateway)
await handle.submitText('/retry')
await handle.submitText('/retry')
expect(renderedText(handle)).not.toContain(copy.retryUnsupported)
expect(callsOf(owner.requestGateway, 'prompt.submit')).toEqual([
expect.objectContaining({ session_id: RUNTIME_SESSION_ID, text: 'second question' })
])
})
it('a replayed /undo 2 cuts both turns from a view without row ids', async () => {
const owner = lostRewindReply()
const unbound = THREE_EXCHANGES.map((row, index) => ({
id: `u${index}`,
parts: [textPart(String(row.content))],
role: row.role as 'assistant' | 'user',
timestamp: index
}))
const handle = await mountWith(owner.requestGateway, unbound)
await handle.submitText('/undo 2')
await handle.submitText('/undo')
expect(owner.active()).toEqual(['first question', 'first answer'])
expect(renderedText(handle)).toContain('first answer')
expect(renderedText(handle)).not.toContain('second question')
expect(renderedText(handle)).not.toContain('third question')
})
it('a view refreshed after the reconnect keeps every exchange the owner still holds', async () => {
const owner = lostRewindReply()
// The reconnect refresh already shows the owner's rewound transcript (the third exchange gone).
const handle = await mountWith(
owner.requestGateway,
boundMessages(['first question', 'first answer', 'second question', 'second answer'])
)
await handle.submitText('/undo')
await handle.submitText('/undo')
expect(owner.active()).toEqual(['first question', 'first answer', 'second question', 'second answer'])
expect(renderedText(handle)).toContain('second question')
expect(renderedText(handle)).toContain('second answer')
})
})
describe('truncateRewoundMessages', () => {
const live = (text: string, role: 'assistant' | 'user') => ({ id: text, parts: [textPart(text)], role, timestamp: 9 })
it('is a no-op when the bound view no longer holds the rewound row', () => {
const messages = boundMessages(['first question', 'first answer', 'second question', 'second answer'])
expect(truncateRewoundMessages(messages as never, 15, 1)).toEqual(messages)
})
it('cuts unbound live bubbles after the bound history by position', () => {
const messages = [
...boundMessages(['first question', 'first answer']),
live('new question', 'user'),
live('new answer', 'assistant')
]
expect(truncateRewoundMessages(messages as never, 13, 1).map(message => message.id)).toEqual(['m0', 'm1'])
})
it('falls back by position on a wholly unbound view', () => {
const messages = [live('a', 'user'), live('b', 'assistant'), live('c', 'user'), live('d', 'assistant')]
expect(truncateRewoundMessages(messages as never, 13, 1).map(message => message.id)).toEqual(['a', 'b'])
})
})
describe('/steer', () => {
@@ -218,6 +613,32 @@ describe('/steer', () => {
expect(renderedText(handle)).toContain(copy.steerQueued('check the tests too'))
})
it('seals the live reply at the steer notice, keeping its running tool live', async () => {
const requestGateway = gateway(method => (method === 'session.steer' ? { status: 'queued' } : undefined))
const tool = { type: 'tool-call', toolCallId: 't1', toolName: 'terminal', args: {}, argsText: '{}', timestamp: 2 }
const live = { id: 'stream-1', role: 'assistant', pending: true, parts: [{ type: 'text', text: 'before', timestamp: 1 }, tool] }
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
seedMessages={[live]}
seedStreamId="stream-1"
/>
)
publishSessionState(RUNTIME_SESSION_ID, { ...handle!.state(), busy: true })
await handle!.submitText('/steer use the other file')
// The next delta (the model's answer to the steer) seeds a bubble BELOW the notice.
const { messages, streamId } = handle!.state()
expect(streamId).toBeNull()
expect(messages.map(message => message.id).indexOf('stream-1')).toBe(0)
expect(renderedText(handle!)).toContain(copy.steerQueued('use the other file'))
expect(messages[0]).toMatchObject({ pending: true, parts: [expect.anything(), tool] })
})
it('queues the text as the next turn when the steer is not accepted', async () => {
publishSessionState(RUNTIME_SESSION_ID, { ...createClientSessionState(RUNTIME_SESSION_ID), busy: true })
const requestGateway = gateway(method => (method === 'session.steer' ? { status: 'rejected' } : undefined))
@@ -7,9 +7,17 @@ import { setComposerDraft } from '@/store/composer'
import { enqueueQueuedPrompt, getQueuedPrompts, serverOwnsComposerQueue } from '@/store/composer-queue'
import { $sessions, resolveComposerSessionKey } from '@/store/session'
import { $sessionStates } from '@/store/session-states'
import { flushStreamDeltas } from '@/store/stream-delta-flush'
import { sealLiveStream } from './rewind'
import type { SubmissionDestination } from './submission-destination'
import { type GatewayRequest, isTargetSessionBusy, type SubmitTextOptions } from './utils'
import {
type GatewayRequest,
isCompressionBusyError,
isSessionBusyError,
isTargetSessionBusy,
type SubmitTextOptions
} from './utils'
// Typed /undo, /retry, /steer and /queue: one behaviour per command on every surface, mirroring the
// Ink TUI on the same shared gateway (ui-tui/src/app/slash/commands/core.ts +
@@ -30,9 +38,11 @@ interface SnapshotRow {
}
interface RewindReceipt {
/** Client-side: the snapshot row a freshly derived rewind named (a replayed receipt has none). */
derivedRowId?: number
rewound_count?: number
status?: string
target_message?: { content?: unknown } | null
target_message?: { content?: unknown; id?: unknown } | null
}
interface SlashOutput {
@@ -59,11 +69,23 @@ export interface SessionCommandEnv<C extends { arg: string }> {
const copy = () => runtimeTranslations().promptNotices.sessionCommands
// The cut (`turnsBack`) of each session's derived rewind still awaiting its reply. A repeat after a
// lost reply replays THAT request, so an unbound view cuts by its count, not the repeat's own.
const pendingRewindCuts = new Map<string, number>()
/** Clip a queued/steered prompt for the confirmation line (Ink's previewOf). */
const previewOf = (text: string) => `${text.slice(0, PREVIEW_CHARS)}${text.length > PREVIEW_CHARS ? '…' : ''}`
const errorText = (err: unknown) => `error: ${err instanceof Error ? err.message : String(err)}`
/** A refused rewind: main's busy text (wait for a running /compress, else stop the reply), or the raw error. */
const rewindErrorText = (err: unknown, command: 'retry' | 'undo') =>
isCompressionBusyError(err)
? copy().busyCompressing(command)
: isSessionBusyError(err)
? copy().busy(command)
: errorText(err)
/** A human-authored user turn (`split_user_originated_turn`): no display-only kind other than a
* typed /steer, a durable row id, and not a compaction handoff carrier. */
function isUserTurn(row: SnapshotRow): boolean {
@@ -110,14 +132,25 @@ export function userTurnFromEnd(rows: readonly SnapshotRow[], turnsBack: number)
}
/** Drop the rewound rows from the rendered transcript: everything from the first message bound to a
* durable row at or after the target. Unbound bubbles fall back to the Nth newest user bubble. */
* durable row at or after the target. A view bound to durable rows that holds none of them already
* shows the cut (a refresh after reconnect): only unbound live bubbles after it fall back to the Nth
* newest user bubble, and a wholly unbound view falls back by position. */
export function truncateRewoundMessages(messages: ChatMessage[], rowId: number, turnsBack: number): ChatMessage[] {
const bound = (message: ChatMessage) =>
message.rowId !== undefined || message.parts.some(part => part.sourceRowId !== undefined)
let cut = messages.findIndex(
message => (message.rowId ?? -1) >= rowId || message.parts.some(part => (part.sourceRowId ?? -1) >= rowId)
)
if (cut < 0) {
const users = messages.flatMap((message, index) => (message.role === 'user' && !message.hidden ? [index] : []))
const tail = messages.length - [...messages].reverse().findIndex(bound)
const after = tail > messages.length ? 0 : tail
const users = messages.flatMap((message, index) =>
index >= after && message.role === 'user' && !message.hidden ? [index] : []
)
cut = users[users.length - turnsBack] ?? -1
}
@@ -136,6 +169,56 @@ export function sessionCommandHandlers<C extends { arg: string }>(env: SessionCo
): Promise<undefined | { count: number; content: unknown }> {
const { render, sessionId, storedSessionId } = out
const nothing = intent === 'retry' ? copy().retryNothing : copy().undoNothing
// An earlier /undo or /retry whose reply was lost is resolved FIRST: the owner already rewound, so
// the transcript a new target would come from may hold no turn this command accepts. The protocol
// re-sends that retained rewind (the owner answers with the original receipt), or answers
// 'nothing' locally, without a request, when none is retained.
const replayed = await requestGateway<RewindReceipt>('session.rewind', {
session_id: sessionId,
derived_target: true,
replay_only: true
})
const replay = Number(replayed?.rewound_count ?? 0) > 0
const receipt = replay ? replayed : await deriveAndRewind(out, turnsBack, intent)
const cut = replay ? (pendingRewindCuts.get(sessionId) ?? turnsBack) : turnsBack
pendingRewindCuts.delete(sessionId)
if (!receipt) {
return
}
const count = Number(receipt?.rewound_count ?? 0)
if (receipt?.status === 'nothing' || count <= 0) {
return void render(nothing)
}
// The receipt names the row the owner rewound; a replayed receipt's row is not this snapshot's.
const receiptRowId = receipt?.target_message?.id
const rowId = Number.isSafeInteger(receiptRowId) ? (receiptRowId as number) : receipt.derivedRowId
if (rowId !== undefined) {
updateSessionState(
sessionId,
state => ({ ...state, messages: truncateRewoundMessages(state.messages, rowId, cut) }),
storedSessionId
)
}
return { count, content: receipt?.target_message?.content }
}
// Derive the Nth newest user turn from the owner's snapshot and rewind it. Resolves undefined after
// it rendered why nothing was rewound.
async function deriveAndRewind(
out: SlashOutput,
turnsBack: number,
intent: 'retry' | 'undo'
): Promise<RewindReceipt | undefined> {
const { render, sessionId } = out
const nothing = intent === 'retry' ? copy().retryNothing : copy().undoNothing
// The resume snapshot carries every row's durable id and primes the CAS revision/generation the
// rewind mutation presents, as Ink's mutateCanonical does.
const snapshot = await requestGateway<{ messages?: SnapshotRow[] }>('session.resume', { session_id: sessionId })
@@ -157,26 +240,16 @@ export function sessionCommandHandlers<C extends { arg: string }>(env: SessionCo
}
}
const rowId = target.row_id as number
// A derived target: retained by operation alone, so a repeat after a lost reply re-sends it.
pendingRewindCuts.set(sessionId, turnsBack)
const receipt = await requestGateway<RewindReceipt>('session.rewind', {
session_id: sessionId,
target_message_id: rowId
target_message_id: target.row_id,
derived_target: true
})
const count = Number(receipt?.rewound_count ?? 0)
if (receipt?.status === 'nothing' || count <= 0) {
return void render(nothing)
}
updateSessionState(
sessionId,
state => ({ ...state, messages: truncateRewoundMessages(state.messages, rowId, turnsBack) }),
storedSessionId
)
return { count, content: receipt?.target_message?.content }
return { ...receipt, derivedRowId: target.row_id as number }
}
// The next-turn queue the composer uses while busy: the owner's durable FIFO for a canonical
@@ -227,7 +300,7 @@ export function sessionCommandHandlers<C extends { arg: string }>(env: SessionCo
out.render(rewound.count === 1 ? copy().undidOne('1') : copy().undidOther(String(rewound.count)))
} catch (err) {
out.render(errorText(err))
out.render(rewindErrorText(err, 'undo'))
}
},
retry: async (ctx: C) => {
@@ -257,7 +330,7 @@ export function sessionCommandHandlers<C extends { arg: string }>(env: SessionCo
await submitPromptText(text, { destination, sessionId: out.sessionId, storedSessionId: out.storedSessionId })
} catch (err) {
out.render(errorText(err))
out.render(rewindErrorText(err, 'retry'))
}
},
steer: async (ctx: C) => {
@@ -292,6 +365,11 @@ export function sessionCommandHandlers<C extends { arg: string }>(env: SessionCo
}
if (accepted) {
// The notice marks where the steer arrived: seal the live reply above it so the
// output the model writes after reading the steer streams below, not above it.
flushStreamDeltas(out.sessionId)
updateSessionState(out.sessionId, state => sealLiveStream(state, { keepRunningTools: true }))
return out.render(copy().steerQueued(previewOf(text)))
}
@@ -298,6 +298,13 @@ export function isSessionBusyError(error: unknown): boolean {
return /session[ _]busy/i.test(error instanceof Error ? error.message : String(error))
}
/** A busy refusal because another writer holds the compression lock (`field: 'compression'`). */
export function isCompressionBusyError(error: unknown): boolean {
const data = error instanceof JsonRpcGatewayError ? (error.data as { field?: unknown; reason?: unknown } | undefined) : null
return data?.reason === 'session_busy' && data.field === 'compression'
}
// prompt.submit refused because another surface (TUI, messaging gateway)
// holds this session's lease (4090 / SESSION_NOT_OWNED, #106217). The gateway
// stamps the machine reason in `error.data.reason`; the prose fallback covers
@@ -6,6 +6,10 @@ export const arSessionCommands = {
undidOther: (count: string) => `تم التراجع عن ${count} رسائل — الرسالة المحذوفة موجودة في مربع الكتابة`,
retryNothing: 'لا يوجد ما يمكن إعادة محاولته',
retryUnsupported: '/retry: لا يمكن إعادة محاولة الوسائط أو أجزاء المحتوى غير المعروفة',
busy: (command: string) =>
`الجلسة مشغولة — ما زال Hermes يرد. أوقف الرد الحالي أولاً (زر الإيقاف، أو Ctrl+C في الطرفية)، ثم شغّل /${command}.`,
busyCompressing: (command: string) =>
`الجلسة مشغولة — يجري تنفيذ /compress؛ شغّل /${command} عند انتهائه.`,
steerUsage: 'الاستخدام: /steer <الطلب>',
steerQueued: (preview: string) => `أُضيف التوجيه إلى قائمة الانتظار — يصل بعد استدعاء الأداة التالي: "${preview}"`,
steerNoActiveTurn: (preview: string) => `لا يوجد دور نشط — أُضيف إلى قائمة الانتظار للدور التالي: "${preview}"`,
@@ -7,6 +7,10 @@ export const deSessionCommands = {
`${count} Nachrichten rückgängig gemacht — die entfernte Nachricht steht im Eingabefeld`,
retryNothing: 'nichts zum Wiederholen',
retryUnsupported: '/retry: Medien oder unbekannte Inhaltsteile können nicht wiederholt werden',
busy: (command: string) =>
`Sitzung beschäftigt — Hermes antwortet noch. Beende zuerst die aktuelle Antwort (Stopp-Taste oder Strg+C im Terminal), dann führe /${command} aus.`,
busyCompressing: (command: string) =>
`Sitzung beschäftigt — ein /compress läuft; führe /${command} aus, wenn es fertig ist.`,
steerUsage: 'Verwendung: /steer <Prompt>',
steerQueued: (preview: string) => `Steuerung eingereiht — kommt nach dem nächsten Tool-Aufruf an: „${preview}“`,
steerNoActiveTurn: (preview: string) => `kein aktiver Turn — für den nächsten eingereiht: „${preview}“`,
@@ -8,6 +8,10 @@ export const enSessionCommands: SessionCommandNotices = {
undidOther: (count: string) => `undid ${count} messages — the removed message is in the composer`,
retryNothing: 'nothing to retry',
retryUnsupported: '/retry: retry does not support media or unknown content parts',
busy: (command: string) =>
`session busy — Hermes is still replying. Stop the current reply first (Stop button, or Ctrl+C in a terminal), then run /${command}.`,
busyCompressing: (command: string) =>
`session busy — a /compress is running; run /${command} when it finishes.`,
steerUsage: 'usage: /steer <prompt>',
steerQueued: (preview: string) => `steer queued — arrives after next tool call: "${preview}"`,
steerNoActiveTurn: (preview: string) => `no active turn — queued for next: "${preview}"`,
@@ -6,6 +6,10 @@ export const esSessionCommands = {
undidOther: (count: string) => `se deshicieron ${count} mensajes — el mensaje eliminado está en el compositor`,
retryNothing: 'nada que reintentar',
retryUnsupported: '/retry: no se pueden reintentar contenidos multimedia o partes desconocidas',
busy: (command: string) =>
`sesión ocupada — Hermes sigue respondiendo. Detén primero la respuesta actual (botón Detener o Ctrl+C en una terminal) y luego ejecuta /${command}.`,
busyCompressing: (command: string) =>
`sesión ocupada — se está ejecutando un /compress; ejecuta /${command} cuando termine.`,
steerUsage: 'uso: /steer <prompt>',
steerQueued: (preview: string) => `indicación en cola — llega tras la próxima llamada a herramienta: "${preview}"`,
steerNoActiveTurn: (preview: string) => `no hay turno activo — en cola para el siguiente: "${preview}"`,
@@ -6,6 +6,10 @@ export const frSessionCommands = {
undidOther: (count: string) => `${count} messages annulés — le message retiré est dans la zone de saisie`,
retryNothing: 'rien à réessayer',
retryUnsupported: '/retry : impossible de réessayer un contenu multimédia ou des parties inconnues',
busy: (command: string) =>
`session occupée — Hermes est encore en train de répondre. Arrêtez d’abord la réponse en cours (bouton Arrêter ou Ctrl+C dans un terminal), puis lancez /${command}.`,
busyCompressing: (command: string) =>
`session occupée — un /compress est en cours ; lancez /${command} quand il sera terminé.`,
steerUsage: 'utilisation : /steer <prompt>',
steerQueued: (preview: string) => `orientation en file — arrive après le prochain appel d’outil : « ${preview} »`,
steerNoActiveTurn: (preview: string) => `aucun tour actif — mis en file pour le suivant : « ${preview} »`,
@@ -6,6 +6,10 @@ export const jaSessionCommands = {
undidOther: (count: string) => `${count} 件のメッセージを元に戻しました — 削除したメッセージは入力欄にあります`,
retryNothing: '再試行するものはありません',
retryUnsupported: '/retry: メディアや不明なコンテンツを含むターンは再試行できません',
busy: (command: string) =>
`セッションが使用中です — Hermes はまだ応答中です。先に現在の応答を停止してから(停止ボタン、またはターミナルでは Ctrl+C)/${command} を実行してください。`,
busyCompressing: (command: string) =>
`セッションが使用中です — /compress を実行中です。完了したら /${command} を実行してください。`,
steerUsage: '使い方: /steer <プロンプト>',
steerQueued: (preview: string) => `誘導を予約しました — 次のツール呼び出しの後に届きます: 「${preview}」`,
steerNoActiveTurn: (preview: string) => `実行中のターンがありません — 次のターンに追加しました: 「${preview}」`,
@@ -6,6 +6,10 @@ export const ruSessionCommands = {
undidOther: (count: string) => `отменено сообщений: ${count} — удалённое сообщение в поле ввода`,
retryNothing: 'нечего повторять',
retryUnsupported: '/retry: нельзя повторить медиа или неизвестные части содержимого',
busy: (command: string) =>
`сеанс занят — Hermes ещё отвечает. Сначала остановите текущий ответ (кнопка «Стоп» или Ctrl+C в терминале), затем выполните /${command}.`,
busyCompressing: (command: string) =>
`сеанс занят — выполняется /compress; выполните /${command}, когда он завершится.`,
steerUsage: 'использование: /steer <запрос>',
steerQueued: (preview: string) => `уточнение в очереди — придёт после следующего вызова инструмента: «${preview}»`,
steerNoActiveTurn: (preview: string) => `нет активного хода — поставлено в очередь на следующий: «${preview}»`,
+4
View File
@@ -42,6 +42,10 @@ export interface SessionCommandNotices {
undidOther: (count: string) => string
retryNothing: string
retryUnsupported: string
/** A rewind refused while a reply is running: main's stop-first guidance (tui_gateway busy_message). */
busy: (command: string) => string
/** The same refusal while another writer holds the compression lock: Stop does not end it, so wait. */
busyCompressing: (command: string) => string
steerUsage: string
steerQueued: (preview: string) => string
steerNoActiveTurn: (preview: string) => string
@@ -6,6 +6,10 @@ export const zhHantSessionCommands = {
undidOther: (count: string) => `已復原 ${count} 則訊息 — 被移除的訊息已放回輸入框`,
retryNothing: '沒有可重試的內容',
retryUnsupported: '/retry:無法重試包含媒體或未知內容的訊息',
busy: (command: string) =>
`會話忙碌中 — Hermes 仍在回覆。請先停止目前回覆(Stop 按鈕,或在終端機中按 Ctrl+C),然後再執行 /${command}。`,
busyCompressing: (command: string) =>
`會話忙碌中 — 正在執行 /compress;完成後再執行 /${command}。`,
steerUsage: '用法:/steer <提示>',
steerQueued: (preview: string) => `引導已排入佇列 — 將在下一次工具呼叫後送達:「${preview}」`,
steerNoActiveTurn: (preview: string) => `目前沒有進行中的回合 — 已排入下一回合:「${preview}」`,
@@ -6,6 +6,10 @@ export const zhSessionCommands = {
undidOther: (count: string) => `已撤销 ${count} 条消息 — 被移除的消息已放回输入框`,
retryNothing: '没有可重试的内容',
retryUnsupported: '/retry:无法重试包含媒体或未知内容的消息',
busy: (command: string) =>
`会话忙碌中 — Hermes 仍在回复。请先停止当前回复(Stop 按钮,或在终端中按 Ctrl+C),然后再运行 /${command}。`,
busyCompressing: (command: string) =>
`会话忙碌中 — 正在运行 /compress;完成后再运行 /${command}。`,
steerUsage: '用法:/steer <提示>',
steerQueued: (preview: string) => `引导已排队 — 将在下一次工具调用后送达:“${preview}”`,
steerNoActiveTurn: (preview: string) => `当前没有进行中的回合 — 已排队到下一回合:“${preview}”`,
@@ -25,6 +25,7 @@ export {
spliceOlderPreservedRows
} from './reconciliation'
export {
hasUnresolvedToolCall,
QUESTION_CARD_TOOLS,
restorePendingBlockingToolCall,
restorePendingClarifyToolCall,
@@ -412,6 +412,10 @@ export interface SettledClarifyProjection {
streamId: string | null
}
/** A tool call that neither returned nor was sealed: it is still running. */
export const hasUnresolvedToolCall = (message: ChatMessage) =>
message.parts.some(part => part.type === 'tool-call' && part.result === undefined && part.completedAt === undefined)
/**
* Find the message that owns a tool call, by its stable call id, anywhere in
* the transcript — not just in the currently-streaming bubble.
@@ -118,6 +118,25 @@ describe('saveAgentPluginSettings (#46600, #87934)', () => {
expect($agentPlugins.get()[0].settings_schema).toEqual([])
})
// The backend's settings save is what announces `plugin.<id>.settings.changed`; a new API key alone
// is the change a plugin's Desktop half most needs to hear about.
it('still makes the settings call when only a secret changed', async () => {
$agentPluginsProfile.set('workbot')
const request = vi.fn(async () => ({ ok: true, plugin: row({ key: 'demo', source: 'user' }) }))
await saveAgentPluginSettings(request as never, {
failMessage: 'fail',
key: 'demo',
profile: 'workbot',
secrets: { DEMO_API_KEY: 'sk-2' },
values: {},
writeSecret: vi.fn(async () => ({ ok: true }))
})
expect(request).toHaveBeenCalledWith('plugins.manage', { action: 'settings', key: 'demo', profile: 'workbot', values: {} })
expect(JSON.stringify(request.mock.calls)).not.toContain('sk-2')
})
// The list is shared across profiles: a save that lands after the list moved
// to another profile must neither patch that list nor refetch over it.
it('leaves a list loaded for another profile alone', async () => {
+5 -1
View File
@@ -476,8 +476,12 @@ export async function saveAgentPluginSettings(
}
}
// A secret-only save still makes the (empty) settings call: the backend's save is what tells a
// Desktop half `plugin.<id>.settings.changed`, and a new API key is the change it most needs.
const wroteSecret = Object.values(opts.secrets).some(Boolean)
const result =
Object.keys(opts.values).length > 0
Object.keys(opts.values).length > 0 || wroteSecret
? await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>(
'plugins.manage',
withProfile({ action: 'settings', key: opts.key, values: opts.values }, opts.profile)
@@ -0,0 +1,20 @@
/** Lets a mid-turn boundary (an accepted steer) paint the session's buffered stream
* deltas before it seals the live bubble, so text the user had already been sent
* never lands on the far side of the boundary. The stream hook owns the buffer. */
type DeltaFlush = (sessionId: string) => void
let activeFlush: DeltaFlush | null = null
export function registerStreamDeltaFlush(flush: DeltaFlush): () => void {
activeFlush = flush
return () => {
if (activeFlush === flush) {
activeFlush = null
}
}
}
export function flushStreamDeltas(sessionId: string) {
activeFlush?.(sessionId)
}
+5 -2
View File
@@ -829,11 +829,14 @@ class _VoiceInputMessage:
class _SeededQueryMessage:
"""Sentinel for a ``-q`` prompt seeded into an interactive session; treated LITERALLY (no slash/!/file-drop)."""
__slots__ = ("images", "text")
__slots__ = ("admission_id", "images", "text")
def __init__(self, text: str, images=None):
def __init__(self, text: str, images=None, admission_id=None):
self.text = text or ""
self.images = list(images or [])
# A ``/queue`` admission the gateway is running (#134889 N5): ``run_conversation`` pumps that
# admission by this id, never by comparing the (sanitized) text.
self.admission_id = admission_id
def __str__(self) -> str:
return self.text
@@ -0,0 +1 @@
bablobanov
+2
View File
@@ -0,0 +1,2 @@
Neckyserg
# PR #117987 author (commit 904a695 on Neckyserg/hermes-agent)
@@ -0,0 +1,2 @@
austinpickett
# PR #139174
+24 -2
View File
@@ -235,7 +235,8 @@ def stop_authority_turns(authority, *, in_process=False, reason=None):
through its own fenced path). ``in_process`` also hard-interrupts the running agent of each
executing in-process turn, or latches the generation ``adopt_agent`` consumes before it exists.
``reason`` (a gateway restart/shutdown) is recorded on each worker it signals, so the turn settles
with that cause; a worker already stopped (the user's Stop came first) keeps none."""
with that cause; a worker already stopped (the user's Stop came first) keeps none. A worker
still being spawned gets the same Stop and cause through latches (``_stop_spawning_managed``)."""
from hermes_state_runtime import RuntimeStoreError
signalled = 0
workers = getattr(authority, '_managed_workers', {})
@@ -250,6 +251,7 @@ def stop_authority_turns(authority, *, in_process=False, reason=None):
# loop escalates on that latch alone (terminate after STOP_ACK_SECONDS).
logger.debug('managed Stop not queued (%s); the latch escalates it', exc.reason)
signalled += 1
signalled += _stop_spawning_managed(authority, workers, reason)
if not in_process:
return signalled
from agent.interrupt_compat import request_hard_interrupt
@@ -257,7 +259,7 @@ def stop_authority_turns(authority, *, in_process=False, reason=None):
running = getattr(authority.runner, '_running_agents', {})
for sid, live in list(authority.sessions.items()):
generation = live.event_stream.execution.get('execution_generation')
if generation is None or sid in workers:
if generation is None or sid in workers or sid in getattr(authority, '_managed_spawning', {}):
continue
adopted_generation, agent = authority.adopted.get(sid, (None, None))
if adopted_generation != generation:
@@ -270,6 +272,26 @@ def stop_authority_turns(authority, *, in_process=False, reason=None):
return signalled
def _stop_spawning_managed(authority, workers, reason):
"""A managed turn whose worker is still being spawned has nothing to signal yet: latch the
Stop for its generation (``adopt_agent`` delivers it to the worker, which is never
bootstrapped) and, for a gateway restart/shutdown, the cause its pre-bootstrap interrupted
result carries (#134889 GW-22). A user's Stop latched first keeps no cause."""
stopped = 0
for sid, generation in list(getattr(authority, '_managed_spawning', {}).items()):
if sid in workers:
continue
stops = authority.pending_stops
if reason and stops.get(sid) != generation:
causes = getattr(authority, 'pending_stop_causes', None)
if causes is None:
causes = authority.pending_stop_causes = {}
causes[sid] = (generation, reason)
stops[sid] = generation
stopped += 1
return stopped
def stop_managed_turns(runner, reason=None):
"""``stop_authority_turns`` across every served authority (whole-runtime shutdown)."""
return sum(stop_authority_turns(authority, reason=reason) for authority in _authorities(runner))
+8 -11
View File
@@ -1268,18 +1268,15 @@ class GatewayShutdownMixin(GatewaySessionEndMixin):
return
with suppress(Exception):
if hasattr(agent, "shutdown_memory_provider"):
# Drain queued memory writes BEFORE teardown (shutdown_all() gives the worker only ~5s, so a
# /reset or rotation could drop them). Bounded; a failure never blocks teardown.
# The memory manager persists per-turn sync and end-of-session extraction on a single
# serialized background worker. shutdown_memory_provider() -> shutdown_all() only gives that
# worker a ~5s bounded drain and abandons (cancels) anything still queued past it, so a
# /reset — or any gateway session rotation that reaches this cleanup path — could silently
# drop writes the session had already handed off. The next session then loads stale memory
# (#73297). Give pending work a bounded head start through the manager's own barrier first,
# mirroring the CLI exit path (cli.py). Best-effort: a flush failure must never block
# teardown.
# Drain queued memory writes BEFORE teardown: shutdown_all() gives the single serialized
# worker only ~5s and cancels the rest, so a /reset or rotation could drop writes and the
# next session loads stale memory (#73297). Bounded, like the CLI exit path; never blocks.
# The teardown claim comes first: a /new that stops waiting on this job evicts the agent,
# and that soft release must leave extraction + provider shutdown to us.
_mm = getattr(agent, "_memory_manager", None)
if _mm is not None and hasattr(_mm, "flush_pending"):
if _mm is not None:
with suppress(Exception):
_mm.claim_teardown(session_end=True)
with suppress(Exception):
_mm.flush_pending(timeout=10)
# Pass the real transcript so ``on_session_end`` hooks don't see the empty default.
+17 -4
View File
@@ -135,6 +135,9 @@ class SessionAuthority:
# Stops accepted for a running generation whose agent does not exist yet
# (first-turn construction); consumed by adopt_agent, keyed session -> generation.
self.pending_stops = {}
# session -> (generation, reason): a gateway restart/shutdown Stop latched before that
# generation's managed worker existed; its pre-bootstrap result carries the cause (GW-22).
self.pending_stop_causes = {}
# session -> (generation, [steer text]) accepted before that generation adopted an agent;
# adopt_agent hands it to that agent (never a later generation's), the drain drops the rest.
self.pending_steers = {}
@@ -625,11 +628,15 @@ class SessionAuthority:
return results
async def submit(self, actor: Principal, request: Submission, *, _authorize_write=None, queue_while_restarting=False,
_replay_guard=None):
_replay_guard=None, _kanban_card=None):
"""``queue_while_restarting``: the caller is an interactive client attached to a restart's
after-turn wait, which commits the input queued for the next owner. Machine callers (a2a,
kanban, hosted rooms) keep the plain ``runtime_draining`` refusal and retry against the next
owner with their own stable request id; none of them reads a released waiter as queued."""
owner with their own stable request id; none of them reads a released waiter as queued.
``_kanban_card`` is the Kanban dispatcher's trusted producer marker (``session_kanban.run_task``
only): committed as ``kanban_card_v1``, it lets a card attempt lost across an owner restart
settle interrupted (#134889 KB-8). Never a payload field: a client cannot supply it."""
self.authorize(actor, request.ref, 'session:submit')
async with self.sessions[request.ref.session_id].mutation_lock:
# Deletion can retire this session while the caller waits on its writer.
@@ -637,9 +644,11 @@ class SessionAuthority:
from gateway.session_ingress_media import capture_lease
with capture_lease(): # captured bytes are held from capture until commit or refusal
return await self._submit(actor, request, _authorize_write=_authorize_write,
queue_only=queue_while_restarting is True, _replay_guard=_replay_guard)
queue_only=queue_while_restarting is True, _replay_guard=_replay_guard,
_kanban_card=_kanban_card)
async def _submit(self, actor, request, *, _authorize_write=None, queue_only=False, _replay_guard=None):
async def _submit(self, actor, request, *, _authorize_write=None, queue_only=False, _replay_guard=None,
_kanban_card=None):
self._require_admission_open(queue_only=queue_only)
if (request.intent != 'queue' or not {'text'} <= set(request.payload) <= {
'text', 'attachments', 'finite', 'unattended', 'surface', 'voice_context', 'interrupted',
@@ -661,6 +670,8 @@ class SessionAuthority:
payload = {'text': request.payload['text'], **finite, **admit_surface(request.payload),
**admit_display(request.payload), **admit_cli_turn(request.payload),
**admit_attachments(request.payload.get('attachments'), admitted=admitted)}
if _kanban_card is not None:
payload['kanban_card_v1'] = _kanban_card
source = self.sessions[request.ref.session_id].source
if source is not None and source.user_id != actor.subject:
# Durable server authorization, not a client payload field. The original
@@ -1147,6 +1158,7 @@ class SessionAuthority:
with live.event_stream.lock:
live.event_stream.execution = {}
self.pending_stops.pop(ref.session_id, None)
self.pending_stop_causes.pop(ref.session_id, None)
self.pending_steers.pop(ref.session_id, None)
self.adopted.pop(ref.session_id, None)
# An automation turn nobody waits on settles here, at the exit that fenced it (GW-10),
@@ -1270,6 +1282,7 @@ class SessionAuthority:
with live.event_stream.lock:
live.event_stream.execution = {}
self.pending_stops.pop(ref.session_id, None)
self.pending_stop_causes.pop(ref.session_id, None)
self.pending_steers.pop(ref.session_id, None)
self.adopted.pop(ref.session_id, None)
from gateway.session_queued_controls import publish_transfers
+2 -1
View File
@@ -17,7 +17,8 @@ def handlers(connection):
def authorize(connection, ref, params, capability):
connection.authority.authorize(connection.actor, ref, capability)
if 'profile' in params:
# The contract's ``profile`` is nullable; null means this owner, as an omitted key does.
if params.get('profile') is not None:
from hermes_cli.profiles import profile_matches_home
profile = params['profile']
if not isinstance(profile, str):
+4 -1
View File
@@ -108,7 +108,10 @@ def one_turn_runtime(override, model, runtime_kwargs, config):
runtime = dict(runtime_kwargs)
current_provider = runtime_kwargs.get('provider') or ''
same_provider = (result.target_provider or '').strip().lower() == current_provider.strip().lower()
if same_provider and not result.resolved_via_alias:
# Only a ``model_aliases`` entry with its own ``base_url`` routes away from the session, as switch_model
# applied it to this result (``_apply_direct_alias_endpoint``); a built-in shortcut, an endpointless entry
# or a process provider's declared id names a model like a plain pick.
if same_provider and not result.alias_endpoint_applied:
# A plain model pick on the session's provider: the explicit provider re-resolved that provider's
# default endpoint and key, and the session already has both (a mirror, a proxy, a pool), so keep
# them. The wire follows the target only where the provider picks it per model (Copilot, Nous
+3 -3
View File
@@ -58,7 +58,7 @@ def _log_refusal(profile, method, exc: RuntimeStoreError) -> None:
args = (method, _profile_label(profile), exc.reason,
f' field={exc.field}' if getattr(exc, 'field', None) else '',
f' detail={exc.detail}' if getattr(exc, 'detail', None) else '')
if getattr(exc, 'field', None) or getattr(exc, 'detail', None):
if exc.reason != 'session_busy' and (getattr(exc, 'field', None) or getattr(exc, 'detail', None)):
logger.warning(line, *args)
else:
logger.debug(line, *args)
@@ -773,13 +773,13 @@ class AuthorityConnection:
async def mutate(self, ref, params):
from gateway.session_mutations import mutate_session
from hermes_state_mutation_guards import MUTATION_GUARD_REFUSALS
from hermes_state_mutation_guards import MUTATION_GUARD_REFUSALS, busy_refusal
try:
result = await mutate_session(self.authority, self.actor, ref, params)
except MUTATION_GUARD_REFUSALS as exc:
# A live turn lease / compression lock is the same retryable busy verdict a
# running admission gets (REST maps it to 409), not an internal error.
raise RuntimeStoreError('session_busy') from exc
raise busy_refusal(exc) from exc
except RuntimeStoreError as exc:
if exc.reason in {'revision_conflict', 'stale_generation'}:
# The fresh fences ride on the refusal, so the client retries once without a re-read.
+18 -2
View File
@@ -141,7 +141,14 @@ async def run_task(connection, params):
payload: dict = {'text': f'work kanban task {params["task_id"]}'}
if committed is None or 'finite' in committed['payload']:
payload['finite'] = True
receipt = await authority.submit(connection.actor, Submission(request_id, ref, payload, 'queue'))
# The dispatcher's trusted producer marker (#134889 KB-8): nobody waits on a card attempt, so one
# lost across an owner restart settles interrupted instead of pausing the card session. An
# attempt main committed without it resubmits unmarked (same digest, original receipt).
card = None
if committed is None or 'kanban_card_v1' in committed['payload']:
card = {'board': context.get('board'), 'task_id': params['task_id'], 'run_id': params['run_id']}
receipt = await authority.submit(connection.actor, Submission(request_id, ref, payload, 'queue'),
_kanban_card=card)
return {'session_id': ref.session_id, 'receipt': asdict(receipt)}
@@ -238,10 +245,19 @@ def _worker_workspace(frame):
if not session_id:
yield None
return
from gateway.session_authorities import served_profile_name
from gateway.session_context import clear_session_vars, set_session_vars
from gateway.session_policy import bind_turn_workspace, restore_policy
from hermes_state_keys import profile_from_session_key
policy = restore_policy(frame['policy'])
tokens = set_session_vars(session_key=frame.get('route') or session_id, session_id=session_id)
route = frame.get('route') or session_id
# The profile its in-process turns bind, so persistent Docker keys the container they and the
# session's side agents use (#140665): a served secondary's name, which its routes carry
# (``agent:<name>:``); none for the launch home, whatever its name (``hermes -p beta gateway``
# keys ``agent:main:``, and only the default profile runs the multiplexer).
profile = served_profile_name(frame.get('home'))
tokens = set_session_vars(session_key=route, session_id=session_id,
profile=profile if profile_from_session_key(route) == profile else '')
try:
bind_turn_workspace(policy, session_id)
yield session_id
+68 -9
View File
@@ -454,6 +454,13 @@ def _interrupted_before_bootstrap(authority, row, cause=None):
return ''
def _latched_stop_cause(authority, session_id, generation):
"""The gateway restart/shutdown cause latched for this generation before its worker existed
(``run_runtime._stop_spawning_managed``), consumed once; None for a user's Stop."""
latched = getattr(authority, 'pending_stop_causes', {}).pop(session_id, None)
return latched[1] if latched and latched[0] == generation else None
def _with_stop_cause(accepted, worker):
"""The wire result carries no interruption cause; the owner recorded which stop it sent."""
if accepted.get('interrupted') and worker.stop_cause:
@@ -514,14 +521,35 @@ def _compression_ancestors(db, session_id):
return ancestors[::-1]
async def execute_managed(authority, ref, row, policy):
from gateway.run_turn_progress import publish_worker_tool_event
# This route never passes the inbound prepare step, so the worker would read ``@`` tokens as typed.
from gateway.session_context_refs import managed_turn_text, refused_turn_result
text, refusal = await managed_turn_text(authority, row, policy)
if refusal is not None:
authority.pending_results[row['admission_id']] = {'result': refused_turn_result(row, refusal), 'usage': {}}
return refusal
def _mark_spawning(authority, ref, row):
"""Mark the generation pre-worker: until the worker registers, a gateway stop has nothing to
signal, so it latches for this generation instead (#134889 GW-22). Set before ``@`` expansion,
which may await the network, so a Stop there is never lost."""
spawning = getattr(authority, '_managed_spawning', None)
if spawning is None:
spawning = authority._managed_spawning = {}
spawning[ref.session_id] = row.get('generation')
return spawning
def _stopped_before_spawn(authority, ref, row):
"""A Stop latched for this generation while it had no worker (a gateway stop during ``@``
expansion, or a user's Stop): consume it and record the interrupted result, never spawning."""
generation = row.get('generation')
stops = getattr(authority, 'pending_stops', {})
if generation is None or stops.get(ref.session_id) != generation:
return False
del stops[ref.session_id]
authority._managed_spawning.pop(ref.session_id, None)
_interrupted_before_bootstrap(authority, row, _latched_stop_cause(authority, ref.session_id, generation))
return True
async def _spawn_registered_worker(authority, ref, row):
"""Spawn the turn's child and register it for Stops: ``(worker, process)``, or None when the spawn was cancelled (the
interrupted result is already recorded). The generation stays marked spawning until registration."""
spawning = _mark_spawning(authority, ref, row)
generation = row.get('generation')
try:
env = await asyncio.to_thread(_worker_env, authority)
cwd = (await asyncio.to_thread(Path(__file__).resolve)).parents[1]
@@ -533,7 +561,12 @@ async def execute_managed(authority, ref, row, policy):
stderr=subprocess.DEVNULL if log_path is None else subprocess.PIPE, **WORKER_POPEN)
except asyncio.CancelledError:
# acquire_process owns late-child cleanup; no bootstrap or reservation was sent.
return _interrupted_before_bootstrap(authority, row)
spawning.pop(ref.session_id, None)
_interrupted_before_bootstrap(authority, row, _latched_stop_cause(authority, ref.session_id, generation))
return None
except BaseException:
spawning.pop(ref.session_id, None)
raise
worker = ManagedWorker(process)
if log_path is not None:
worker.drain_stderr(log_path)
@@ -541,6 +574,32 @@ async def execute_managed(authority, ref, row, policy):
if workers is None:
workers = authority._managed_workers = {}
workers[ref.session_id] = worker
spawning.pop(ref.session_id, None)
worker.stop_cause = _latched_stop_cause(authority, ref.session_id, generation)
return worker, process
async def execute_managed(authority, ref, row, policy):
from gateway.run_turn_progress import publish_worker_tool_event
# This route never passes the inbound prepare step, so the worker would read ``@`` tokens as typed.
from gateway.session_context_refs import managed_turn_text, refused_turn_result
_mark_spawning(authority, ref, row)
try:
text, refusal = await managed_turn_text(authority, row, policy)
except BaseException:
authority._managed_spawning.pop(ref.session_id, None)
raise
if refusal is not None:
authority._managed_spawning.pop(ref.session_id, None)
authority.pending_results[row['admission_id']] = {'result': refused_turn_result(row, refusal), 'usage': {}}
return refusal
if _stopped_before_spawn(authority, ref, row):
return ''
spawned = await _spawn_registered_worker(authority, ref, row)
if spawned is None:
return ''
worker, process = spawned
workers = authority._managed_workers
# A Stop acknowledged during the env/spawn awaits found no worker and was latched for this
# generation; consume it in the same step that makes the worker reachable to interrupt_managed.
authority.adopt_agent(ref.session_id, row['generation'], worker)
+1 -1
View File
@@ -9,7 +9,7 @@ from hermes_state_runtime import RuntimeStoreError
# Fields ``SessionAuthority.submit`` commits for a local session's input; producers commit their own envelope.
LOCAL_INPUT_FIELDS = frozenset({'text', 'attachments_v1', 'finite', 'unattended', 'surface_v1', 'display_v1',
'cli_turn_v1'})
'cli_turn_v1', 'kanban_card_v1'})
def is_viewer_input(row):
+22 -4
View File
@@ -84,8 +84,17 @@ def finish_result(db, *, epoch, row, response, outcome, result=None, prepared=No
result = load_result(saved[0])
return dict(current), result['result'].get('final_response') or ''
outcome, stored = prepared or prepare_result(row, response, outcome, result)
# A turn the owner's restart/stop interrupted: its reason commits as a Hermes transcript notice
# in this same write, so a viewer that reconnects after the restart reads why (#134889 GW-21);
# the live ``message.complete.warning`` carries the same copy. A retry that finds the row
# terminal returns above, so the notice is written once.
from gateway.session_settlement_recovery import owner_stop_notice
notice = owner_stop_notice(result, outcome, row)
write = None if notice is None else (lambda conn, _current: close_discarded_turn(
db, conn, row, notice, replace_after=row.get('transcript_mark')))
settled = settle_session_input(db, epoch=epoch, admission_id=row['admission_id'],
generation=row['generation'], outcome=outcome, result=stored, _transfers=transfers)
generation=row['generation'], outcome=outcome, result=stored, _transfers=transfers,
_terminal_write=write)
return settled, response
@@ -99,7 +108,7 @@ def admission_result(db, admission_id):
return load_result(saved[0]) if saved else None
def close_discarded_turn(db, conn, row, notice=None):
def close_discarded_turn(db, conn, row, notice=None, *, replace_after=None):
"""Close a discarded ``unknown`` turn the way a failed turn is closed, on the resolution's
own transaction (``resolve_unknown_session_input(_terminal_write=...)``).
@@ -116,14 +125,23 @@ def close_discarded_turn(db, conn, row, notice=None):
gets the same boundary (merged onto the call row, whose calls are then pruned).
``notice`` (an unattended automation turn settled by the owner, GW-10) replaces the hedged copy
and is written even on a closed tail: nobody resolved it, so the transcript must say why."""
and is written even on a closed tail: nobody resolved it, so the transcript must say why.
``replace_after`` (the claim's transcript mark, an owner-stop notice): when the tail is a
failed-turn boundary written after that mark, it is THIS turn's own (the agent's core closer
wrote "Send it again" for the interrupted turn), so the notice replaces its copy instead of
stacking a second boundary (#134889 GW-21). An earlier turn's boundary stays."""
import time
from agent.turn_failure_copy import FAILED_TURN_DISPLAY_KIND, PARTIAL_FAILED_TURN_NOTICE
from hermes_state_local_lineage import local_physical_target
from hermes_state_runtime import _canonical_chain
target = _canonical_chain(conn, local_physical_target(conn, row['target_session_id']))[-1]
tail = conn.execute("SELECT role, tool_calls FROM messages WHERE session_id=? AND active=1 "
tail = conn.execute("SELECT role, tool_calls, id, display_kind FROM messages WHERE session_id=? AND active=1 "
"AND role NOT IN ('session_meta','system') ORDER BY id DESC LIMIT 1", (target,)).fetchone()
if (notice is not None and type(replace_after) is int and tail is not None and tail[0] == 'assistant'
and tail[3] == FAILED_TURN_DISPLAY_KIND and tail[2] > replace_after):
conn.execute("UPDATE messages SET content=?, display_metadata=NULL WHERE id=?", (notice, tail[2]))
return True
if notice is None and (tail is None or not (
tail[0] == 'user' or (tail[0] == 'assistant' and tail[1] not in (None, '', '[]')))):
return False
+13 -5
View File
@@ -99,7 +99,7 @@ def _publish_completion(authority, live, ref, row, settled, response, captured):
except Exception:
logger.exception('Terminal control cleanup failed for admission %s', row['admission_id'])
complete = completion_payload(row, settled, response, captured)
notice = owner_stop_notice(captured, settled['outcome'])
notice = owner_stop_notice(captured, settled['outcome'], row)
if notice and not complete.get('warning'):
# ``warning`` is the slot every surface already renders on message.complete (Ink activity
# line, Desktop toast), so a turn the owner's stop cut short says why (#134889 F1).
@@ -113,17 +113,25 @@ SHUTDOWN_INTERRUPT_NOTICE = ('Gateway shutting down: this turn was interrupted a
'Send your message again once Hermes is back.')
def owner_stop_notice(captured, outcome):
def owner_stop_notice(captured, outcome, row=None):
"""The named notice for a turn the owner's restart/stop interrupted, or None. With the default
``agent.restart_drain_timeout: 0`` a restart interrupts in-flight turns at once; the admission
settles ``interrupted`` and is never replayed, so the viewer is told the restart did it."""
settles ``interrupted`` and is never replayed, so the viewer is told the restart did it.
An unattended ``row`` (cron, automation, webhook) has nobody to resend it, so it gets the
neutral automation copy instead of resend advice (#134889 GW-20, GW-10's rule)."""
# The cause is the one the turn recorded when it was interrupted (stop() interrupts with these
# reasons), never the runner's flags at publication: a user Stop whose settlement was delayed
# until after shutdown began is still the user's Stop.
from gateway.run import _INTERRUPT_REASON_GATEWAY_RESTART, _INTERRUPT_REASON_GATEWAY_SHUTDOWN
cause = ((captured or {}).get('result') or {}).get('interrupt_message') if outcome == 'interrupted' else None
return {_INTERRUPT_REASON_GATEWAY_RESTART: RESTART_INTERRUPT_NOTICE,
_INTERRUPT_REASON_GATEWAY_SHUTDOWN: SHUTDOWN_INTERRUPT_NOTICE}.get(cause)
notice = {_INTERRUPT_REASON_GATEWAY_RESTART: RESTART_INTERRUPT_NOTICE,
_INTERRUPT_REASON_GATEWAY_SHUTDOWN: SHUTDOWN_INTERRUPT_NOTICE}.get(cause)
if notice is not None and row is not None:
from gateway.session_unattended_unknown import is_unattended
if is_unattended(row):
from agent.turn_failure_copy import AUTOMATION_INTERRUPTED_NOTICE
return AUTOMATION_INTERRUPTED_NOTICE
return notice
def _release_media(authority, row):
+17 -1
View File
@@ -29,7 +29,8 @@ def handlers(connection):
'rollback.diff': partial(rollback, connection, kind='diff'),
'rollback.restore': partial(rollback, connection, kind='restore'),
'rollback.session_diff': partial(rollback, connection, kind='session_diff'),
'session.context_breakdown': partial(context_breakdown, connection)}
'session.context_breakdown': partial(context_breakdown, connection),
'session.history': partial(session_history, connection)}
def _profile_home(authority):
@@ -116,6 +117,21 @@ async def export_session(connection, ref, params):
return {'format': fmt, 'filename': default_save_filename(target, fmt), 'content': content}
async def session_history(connection, ref, params):
"""Main's ``session.history``: the display transcript of the conversation's current physical row (compression tip or local receipt entry), ancestors included, each row with its durable ``row_id``; Desktop's edit resolves its turn by it."""
_live_ref(connection, ref, params, {'session_id', 'profile'}, 'session:read')
authority = connection.authority
from gateway.session_local_recovery import transcript_target
# On the loop, as ``attach`` resolves it: a local restore publishes the live entry.
target = transcript_target(authority, ref)
def read():
from gateway.session_controls import display_projection
rows = authority.db.get_messages_as_conversation(target, include_ancestors=True, include_row_ids=True)
return {'count': len(rows), 'messages': display_projection(rows, authority.profile_id)}
return await asyncio.to_thread(read)
async def reload_env(connection, ref, params):
"""``/reload``: re-read the profile's ``.env`` (main's ``reload.env`` / CLI ``/reload``).
+8 -2
View File
@@ -56,11 +56,17 @@ def _admit(connection, ref, params, prefix, required='text', optional=()):
def _side_scope(authority, policy, task_id):
"""The owning profile's home/secrets, the session's frozen policy, and the task's own session
identity (approval key, HERMES_SESSION_ID) for the side agent's thread."""
from gateway.session_authorities import owner_scope
from gateway.session_authorities import owner_scope, served_profile_name
from gateway.session_context import clear_session_vars, set_session_vars
from gateway.session_policy import bind_turn_workspace, policy_scope, unbind_turn_workspace
from tools.environments.local import _is_routed_home
# The owning profile, as its own turns bind it: a served secondary by its name, so persistent
# Docker keys ITS workspace container (never the launch profile's live one for the same folder);
# the launch home binds none, like its ``agent:main`` turns.
home = authority.profile_id
profile = served_profile_name(home) if _is_routed_home(home) else ''
with owner_scope(authority, hydrate_secrets=True), policy_scope(policy, authority=authority):
tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd)
tokens = set_session_vars(session_key=task_id, session_id=task_id, cwd=policy.cwd, profile=profile)
try:
# The session's workspace, as its own turns get it (#140665): mounted at /workspace in a
# per-task container, or the session's own persistent workspace container (same digest).
+5 -2
View File
@@ -4,7 +4,7 @@ A turn lost across an owner restart is fenced ``unknown`` and pauses its session
only the person who sent it knows whether to resend it. An automation delivery has no such person:
a cron run (``CRON_PRINCIPAL_ID``), a process completion or heartbeat (``automation:<route>``,
``local_automation_v1``, a native envelope's ``automation`` block) or a webhook delivery
(``webhook_delivery``). Pausing on it stalls every message queued behind it until an operator
(``webhook_delivery``) or a Kanban card attempt (``kanban_card_v1``, set by the dispatcher). Pausing on it stalls every message queued behind it until an operator
notices (a Bot Chat sat ten hours behind one).
Such a row settles through the same fenced resolution a person's Discard uses
@@ -28,7 +28,10 @@ def is_unattended(row) -> bool:
payload = row.get('payload') or {}
native = payload.get('native_text_v1') or {}
# A webhook delivery: the provider already got its 202 and nobody can be asked to resend it.
return 'local_automation_v1' in payload or 'automation' in native or 'webhook_delivery' in native
# A Kanban card attempt (#134889 KB-8): the dispatcher's marker, committed only by
# ``SessionAuthority.submit(_kanban_card=...)``; a card row main minted without it still pauses.
return ('local_automation_v1' in payload or 'kanban_card_v1' in payload
or 'automation' in native or 'webhook_delivery' in native)
def _unattended_unknown(rows):
+9
View File
@@ -490,6 +490,15 @@ def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage:
)
def manifest_config_schema(manifest: Any) -> Any:
"""Raw ``extensions.com.nousresearch.hermes.config_schema`` of a parsed ``plugin.json`` (Agent
Plugins v1 forbids unknown top-level fields), else ``None``. Unvalidated: the settings form
filters it, the loader and ``plugins validate`` report what is malformed."""
extensions = manifest.get("extensions") if isinstance(manifest, dict) else None
hermes = extensions.get(_HERMES_EXTENSION) if isinstance(extensions, dict) else None
return hermes.get("config_schema") if isinstance(hermes, dict) else None
def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]:
"""Validate only root ``plugin.json`` without discovering components."""
_root, manifest, diagnostics = _validate_root(plugin_root)
+6
View File
@@ -182,7 +182,13 @@ def dispatch_plugin_auth(action: str, args: Any, provider: str) -> bool:
True = the plugin owned the action (core prints nothing more). False = run the built-in path. A
handler exception becomes a readable ``SystemExit`` naming provider and action.
``<plugin>:<id>`` names an OAuth client a plugin declares under ``oauth:`` (:mod:`hermes_cli.plugin_oauth`).
"""
from hermes_cli.plugin_oauth import handle_auth_action
owned = handle_auth_action(action, args, provider)
if owned is not None:
return owned
handler = plugin_auth_handler(provider)
if handler is None:
return False
+33 -9
View File
@@ -71,11 +71,18 @@ class CLIChatTurnMixin:
turn_route = self._resolve_turn_agent_config(message)
if turn_route["signature"] != self._active_agent_route_signature:
_retire_agent(self)
if self.agent is None:
_cprint(f"{_DIM}{t('cli.chat.initializing_agent')}{_RST}")
if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"],
request_overrides=turn_route.get("request_overrides")):
return None
# A first-turn agent build shows in the live spinner (the spinner thread repaints it), never
# as a scrollback line.
initializing = t("cli.chat.initializing_agent") if self.agent is None else ""
if initializing:
self._spinner_text = initializing
try:
if not self._init_agent(model_override=turn_route["model"], runtime_override=turn_route["runtime"],
request_overrides=turn_route.get("request_overrides")):
return None
finally:
if initializing and getattr(self, "_spinner_text", "") == initializing:
self._spinner_text = ""
agent = self.agent
if agent is None:
return None
@@ -92,8 +99,9 @@ class CLIChatTurnMixin:
if isinstance(message, TimelineNotification):
message = str(message) # UI metadata is on the staged row, never in model content.
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
_cprint("")
if not getattr(self, "_modern_layout", False):
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
_cprint("")
from agent.notification_presentation import notification_config_snapshot, notification_policy_snapshot
with notification_policy_snapshot(agent, "cli", notification_config_snapshot()):
@@ -246,6 +254,10 @@ class CLIChatTurnMixin:
# display_callback only when token streaming is off: with streaming on,
# _stream_delta already renders the text and this would print it twice.
def display_callback(sentence: str):
if not turn.box_opened and getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import answer_label
turn.box_opened = True
_cprint(answer_label())
if not turn.box_opened:
turn.box_opened = True
label = " ☤ Hermes "
@@ -624,7 +636,12 @@ class CLIChatTurnMixin:
# intermediate turn boundaries (tool loops) and re-rendered the box after the answer.
if self.show_reasoning and turn.result and not self._reasoning_shown_this_turn:
reasoning = turn.result.get("last_reasoning")
if reasoning:
if reasoning and getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import thought_rows
_cprint("")
for line in thought_rows(None, reasoning, self.reasoning_full):
_cprint(line)
elif reasoning:
w = self._scrollback_box_width()
r_label = f" {t('cli.chat.reasoning_label')} "
r_top = f"{_DIM}┌─{r_label}{'─' * max(w - 3 - len(r_label), 0)}┐{_RST}"
@@ -667,15 +684,22 @@ class CLIChatTurnMixin:
already_streamed = (
(self._stream_started and self._stream_box_opened) or _interrupted_streamed
) and not is_error_response
modern = getattr(self, "_modern_layout", False)
if turn.use_streaming_tts and turn.box_opened and not is_error_response:
# Text already printed sentence-by-sentence; just close the box.
_cprint(f"\n{_ACCENT}╰{'─' * (self._scrollback_box_width() - 2)}╯{_RST}")
if not modern:
_cprint(f"\n{_ACCENT}╰{'─' * (self._scrollback_box_width() - 2)}╯{_RST}")
elif already_streamed:
# _flush_stream() already closed the streamed box; a post-stream transform
# hook shows a suffix for append-only changes, else the full replacement.
_post_stream_text = _post_stream_transform_output(response, turn.result)
if _post_stream_text.strip():
_cprint(_post_stream_text)
elif modern:
from hermes_cli.cli_modern_layout import answer_label
_cprint(answer_label())
ChatConsole().print(_render_final_assistant_content(response, mode=self.final_response_markdown),
style=_resp_text, soft_wrap=True) # terminal wraps; resize re-flows
else:
ChatConsole().print(Panel(
_render_final_assistant_content(response, mode=self.final_response_markdown),
+1
View File
@@ -209,6 +209,7 @@ def _cli_config_defaults():
"resume_display": "full", "resume_exchanges": 10, "resume_max_user_chars": 300,
"resume_max_assistant_chars": 200, "resume_max_assistant_lines": 3, "resume_skip_tool_only": True,
"show_reasoning": True, "reasoning_full": False, "streaming": True, "busy_input_mode": "interrupt",
"cli_layout": "modern",
"persistent_output": True, "persistent_output_max_lines": 200,
# Also clear scrollback on redraw/resize recovery; off because users prefer history.
"cli_rebuild_scrollback_on_redraw": False,
+5 -1
View File
@@ -121,8 +121,11 @@ class CLIInitMixin:
enabled=display.get("persistent_output", True),
max_lines=display.get("persistent_output_max_lines", 200),
)
from hermes_cli.cli_modern_layout import busy_input_mode_from_config, layout_from_config, set_active
self._modern_layout = layout_from_config(display) # display.cli_layout: modern | classic
set_active(self._modern_layout)
# busy_input_mode: "interrupt" (redirect the run) | "queue" (next turn) | "steer" (inject mid-run).
_bim = str(display.get("busy_input_mode", "interrupt")).strip().lower()
_bim = busy_input_mode_from_config(display)
self.busy_input_mode = _bim if _bim in ("queue", "steer") else "interrupt"
# verbose ONLY controls global DEBUG logging; tool_progress="verbose" is independent
@@ -220,6 +223,7 @@ class CLIInitMixin:
# --api-key wins; otherwise a URL-bearing startup alias carries its own credential.
# See #28660.
self._explicit_api_key = api_key or _startup_api_key_override or None
self._typed_api_key = api_key or None # a startup resume it cannot bind is refused; an alias's falls back
self._explicit_base_url = base_url or _startup_base_url_override or None
# Resolved lazily at use-time via _ensure_runtime_credentials().
+91 -13
View File
@@ -10,6 +10,7 @@ import threading
import time
from agent.i18n import t
from hermes_cli.gateway_client import GatewayClientError
from rich.markup import escape as _escape
@@ -352,29 +353,79 @@ class CLILoopsMixin:
q.not_empty.notify_all()
return before, after
def _gateway_queue_store(self):
"""The gateway session store when this CLI is a viewer of the gateway's session, else None."""
from hermes_cli.gateway_tools_notify import gateway_store
return gateway_store(self) if getattr(self, "session_id", None) else None
def _queue_items(self) -> list:
"""``[(admission_id | None, item)]``: the owner's queued admissions (run first, in its FIFO
order), then the turns still waiting in ``_pending_input``."""
items: list = []
store = self._gateway_queue_store()
if store is not None:
from hermes_cli import gateway_queue
items = list(gateway_queue.queued_rows(self, store))
return items + [(None, item) for item in self._pending_input_items()]
def _queue_enqueue(self, text: str) -> None:
from cli import _cprint
payload = self._expand_paste_references(text)
self._pending_input.put(payload)
store = self._gateway_queue_store() if self._agent_running else None
if store is not None:
# #134889 N5: the gateway's durable FIFO holds it (committed at accept, kept across an owner
# restart, run once after the current turn), never this process.
from hermes_cli import gateway_queue
try:
gateway_queue.submit_queued(self, store, payload)
except gateway_queue.QueueOutcomeUnknown as exc: # sent, no answer: it may be queued
_cprint(f" {t('cli.queue.submit_unknown', error=exc)}")
return
except GatewayClientError as exc: # the owner refused, or it was never sent: nothing is held here
_cprint(f" {t('cli.queue.gateway_refused', error=exc)}")
return
else:
self._pending_input.put(payload)
key = "cli.queue.queued_next_turn" if self._agent_running else "cli.queue.queued"
_cprint(f" {t(key, preview=_preview(payload))}")
def _queue_list(self, rest: str) -> None:
from cli import _VoiceInputMessage, _cprint
items = self._pending_input_items()
items = self._queue_items()
if not items:
_cprint(f" {t('cli.queue.empty')}" + ("" if rest else " " + _queue_usage()))
return
_cprint(f" {t('cli.queue.pending_header', count=len(items))}")
for idx, item in enumerate(items, 1):
for idx, (_, item) in enumerate(items, 1):
tag = " [voice]" if isinstance(item, _VoiceInputMessage) else ""
_cprint(f" {idx}. {_preview(str(item).replace(chr(10), ' '))}{tag}")
def _queue_gateway_call(self, verb, index, *args):
"""One owner verb on queued item ``index``; its refusal is printed (in its own words), never raised."""
from cli import _cprint
from hermes_cli import gateway_queue
from hermes_cli.gateway_client import GatewayRPCError
try:
return getattr(gateway_queue, verb)(self, self._gateway_queue_store(), *args)
except GatewayRPCError as exc:
if str(exc) in gateway_queue.STARTED_REFUSALS: # the drain claimed it first: it runs as sent
_cprint(f" {t('cli.queue.already_started', index=index)}")
else:
key = "cli.queue.edit_refused" if verb == "replace" else "cli.queue.remove_refused"
_cprint(f" {t(key, index=index, error=exc)}")
except GatewayClientError as exc: # no answer: the owner may have applied it
_cprint(f" {t('cli.queue.change_unknown', index=index, error=exc)}")
return None
def _queue_clear(self, rest: str) -> None:
from cli import _cprint
owned = [admission for admission, _ in self._queue_items() if admission]
cleared = sum(1 for idx, admission in enumerate(owned, 1)
if self._queue_gateway_call("cancel", idx, admission) == "cancelled")
before, _ = self._mutate_pending_input(lambda items: [])
key = "cli.queue.cleared_one" if len(before) == 1 else "cli.queue.cleared_other"
_cprint(f" {t(key, count=len(before))}")
cleared += len(before)
key = "cli.queue.cleared_one" if cleared == 1 else "cli.queue.cleared_other"
_cprint(f" {t(key, count=cleared)}")
def _queue_remove(self, rest: str) -> None:
from cli import _cprint
@@ -383,13 +434,26 @@ class CLILoopsMixin:
if idx is None:
_cprint(f" {t('cli.queue.usage_remove')}")
return
items = self._queue_items()
owned = sum(1 for admission, _ in items if admission)
if 1 <= idx <= owned:
admission, text = items[idx - 1]
verdict = self._queue_gateway_call("cancel", idx, admission)
if verdict == "cancelled":
_cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(text)))}")
elif verdict == "stopped": # claimed between the list and the cancel: the owner interrupted it
_cprint(f" {t('cli.queue.started_stopped', index=idx)}")
elif verdict == "started":
_cprint(f" {t('cli.queue.already_started', index=idx)}")
return
local = idx - owned
removed: list = []
before, _ = self._mutate_pending_input(
lambda items: (removed.append(items.pop(idx - 1)) or items) if 1 <= idx <= len(items) else items)
lambda items: (removed.append(items.pop(local - 1)) or items) if 1 <= local <= len(items) else items)
if removed:
_cprint(f" {t('cli.queue.removed', index=idx, preview=_preview(str(removed[0])))}")
else:
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}")
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}")
def _queue_edit(self, rest: str) -> None:
from cli import _VoiceInputMessage, _cprint
@@ -402,18 +466,26 @@ class CLILoopsMixin:
return
new_prompt = bits[1]
new_text = self._expand_paste_references(new_prompt.strip())
items = self._queue_items()
owned = sum(1 for admission, _ in items if admission)
if 1 <= idx <= owned:
# The owner admits the edit as a new admission at the back of its FIFO.
if self._queue_gateway_call("replace", idx, items[idx - 1][0], new_text):
_cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}")
return
local = idx - owned
def _edit(items: list) -> list:
if 1 <= idx <= len(items):
if 1 <= local <= len(items):
# A voice-queued item keeps its sentinel so the concise voice-response
# prefix still applies (#65827).
voice = isinstance(items[idx - 1], _VoiceInputMessage)
items[idx - 1] = _VoiceInputMessage(new_text) if voice else new_text
voice = isinstance(items[local - 1], _VoiceInputMessage)
items[local - 1] = _VoiceInputMessage(new_text) if voice else new_text
return items
before, after = self._mutate_pending_input(_edit)
if before == after:
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=len(before))}")
_cprint(f" {t('cli.queue.item_not_found', index=idx, size=owned + len(before))}")
else:
_cprint(f" {t('cli.queue.updated', index=idx, preview=_preview(new_text))}")
@@ -424,6 +496,12 @@ class CLILoopsMixin:
if src is None or dst is None:
_cprint(f" {t('cli.queue.usage_move')}")
return
owned = sum(1 for admission, _ in self._queue_items() if admission)
if owned and min(src, dst) <= owned and src != dst:
# The owner's FIFO runs admissions in the order they were sent; it has no reorder verb.
_cprint(f" {t('cli.queue.move_gateway_fifo')}")
return
src, dst = src - owned, dst - owned
def _move(items: list) -> list:
if 1 <= src <= len(items) and 1 <= dst <= len(items):
@@ -432,9 +510,9 @@ class CLILoopsMixin:
before, after = self._mutate_pending_input(_move)
if before == after and src != dst:
_cprint(f" {t('cli.queue.move_out_of_range', size=len(before))}")
_cprint(f" {t('cli.queue.move_out_of_range', size=owned + len(before))}")
else:
_cprint(f" {t('cli.queue.moved', source=src, destination=dst)}")
_cprint(f" {t('cli.queue.moved', source=src + owned, destination=dst + owned)}")
def _cmd_queue(self, cmd_original: str):
"""``/queue <prompt>`` enqueues; a leading management verb whose arguments fit
+379
View File
@@ -0,0 +1,379 @@
"""Glyph-grammar transcript and bottom-anchored chrome for the classic CLI (``display.cli_layout``).
Every row this module prints into scrollback is width-independent (a column-0 glyph, no boxes, no
full-width rules), so resizing the terminal cannot re-wrap and garble what is already printed.
Width-dependent chrome (the composer's top rule and the footer) lives only in prompt_toolkit's live
region, which is redrawn on every resize. ``display.cli_layout: classic`` restores the boxed look.
"""
from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime
from agent.i18n import t
logger = logging.getLogger(__name__)
_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
_RST, _BOLD, _DIM = "\x1b[0m", "\x1b[1m", "\x1b[2m"
_RED = "\x1b[38;2;239;83;80m"
_TITLE_MAX = 72
def is_modern(cli) -> bool:
return bool(getattr(cli, "_modern_layout", False))
def layout_from_config(display: dict) -> bool:
"""True for the glyph layout (default); ``classic`` keeps the boxed transcript."""
return str((display or {}).get("cli_layout", "modern") or "modern").strip().lower() != "classic"
def busy_input_mode_from_config(display: dict) -> str:
"""``display.busy_input_mode``; unset interrupts in both layouts (Tab still queues in this one)."""
return str((display or {}).get("busy_input_mode") or "interrupt").strip().lower()
def _skin_hex(key: str, fallback: str) -> str:
from hermes_cli.skin_engine import get_active_skin
return get_active_skin().get_color(key, fallback) or fallback
def _fg(hex_color: str, bold: bool = False) -> str:
try:
r, g, b = (int(hex_color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
return f"\x1b[{'1;' if bold else ''}38;2;{r};{g};{b}m"
except (ValueError, IndexError):
return _BOLD if bold else ""
def _brand_glyph() -> tuple[str, str]:
"""(glyph, name) from the skin's response label, e.g. ``" ☤ Hermes "`` → ("☤", "Hermes")."""
from hermes_cli.skin_engine import get_active_skin
label = get_active_skin().get_branding("response_label", " ☤ Hermes ")
parts = label.split(None, 1)
return (parts[0], parts[1].strip()) if len(parts) == 2 else ("☤", label.strip() or "Hermes")
def prompt_glyph() -> str:
from hermes_cli.skin_engine import get_active_skin
return get_active_skin().get_branding("prompt_symbol", "❯").strip() or "❯"
# ── transcript rows (printed into scrollback) ───────────────────────────────────────────────
# Fixed width, not the terminal's: a 40-cell rule never re-wraps on resize (classic's user rule).
_SEPARATOR_WIDTH = 40
def separator(accent: bool) -> str:
"""The bar above each user message and answer (accent) or reasoning block (dim)."""
color = _fg(_skin_hex("ui_accent", "#FFBF00")) if accent else _DIM
return f"{color}{'─' * _SEPARATOR_WIDTH}{_RST}"
def user_echo_markup(text: str, accent_hex: str, ts_suffix: str = "") -> str:
"""Rich markup for a submitted single-line user message: ``❯ text``."""
from rich.markup import escape
return f"[bold {accent_hex}]{escape(prompt_glyph())}[/] [bold]{escape(text)}[/]{ts_suffix}"
def answer_label(timestamp: str = "") -> str:
"""The rows that open an answer: the accent bar, then the skin's brand glyph + name."""
glyph, name = _brand_glyph()
stamp = f" {_DIM}{timestamp}{_RST}" if timestamp else ""
return f"\n{separator(True)}\n{_fg(_skin_hex('ui_accent', '#FFBF00'), bold=True)}{glyph} {name}{_RST}{stamp}"
def thought_title(text: str) -> str:
"""Short title for a reasoning block: its first ``**bold**`` heading, else its first sentence."""
text = (text or "").strip()
match = re.search(r"\*\*(.+?)\*\*", text)
title = match.group(1) if match else re.split(r"(?<=[.!?])\s|\n", text, maxsplit=1)[0]
title = " ".join(title.split()).strip("*# ")
return title if len(title) <= _TITLE_MAX else title[:_TITLE_MAX - 1].rstrip() + "…"
def thought_line(seconds: float | None, text: str) -> str:
"""``∴ Thought for 3s · Planning the change`` (dim italic), closing a reasoning block."""
title = thought_title(text)
took = t("cli.modern.thought_for", seconds=f"{max(seconds or 0.0, 0.0):.0f}") if seconds else t("cli.modern.thought")
body = f"{took} · {title}" if title else took
return f"\x1b[2;3m∴ {body}{_RST}"
_REASONING_PREVIEW_LINES = 10 # classic's post-hoc reasoning box shows this many unless /reasoning full
def reasoning_text_line(line: str) -> str:
"""One streamed reasoning line: dim, no frame (the dim bar above opens the block)."""
return f"{_DIM}{line}{_RST}"
def thought_rows(seconds: float | None, text: str, full: bool) -> list[str]:
"""A reasoning block printed after the fact (no live stream): dim bar, its text (first 10
lines unless ``full``, as classic), then the ``∴ Thought for 3s`` summary."""
lines = (text or "").strip().splitlines()
shown = lines if full or len(lines) <= _REASONING_PREVIEW_LINES else lines[:_REASONING_PREVIEW_LINES]
rows = [separator(False), *(reasoning_text_line(line) for line in shown)]
if len(shown) < len(lines):
rows.append(f"{_DIM} {t('cli.chat.reasoning_more_lines', count=len(lines) - len(shown))}{_RST}")
return [*rows, thought_line(seconds, text)]
def completed_tool_line(cli, function_name: str, args: dict | None, duration: float, result=None,
is_error: bool = False) -> str:
"""The scrollback line for a finished tool call in the active layout."""
if is_modern(cli):
return tool_row(function_name, args, duration, result, is_error)
from agent.display import get_cute_tool_message
return f" {get_cute_tool_message(function_name, args, duration, result=result)}"
def tool_row(function_name: str, args: dict | None, duration: float, result=None, is_error: bool = False) -> str:
"""``📖 Read calc.py 0.2s`` / ``✗ $ pytest -q 0.4s [exit 1]`` — one row per completed call, led by
the tool's emoji (the classic ``┊`` row's), or a red ``✗`` when that entry failed."""
from agent.display import get_tool_row_parts
rows = get_tool_row_parts(function_name, args or {}, result, is_error=bool(is_error))
out = []
for index, (emoji, verb, detail, suffix) in enumerate(rows):
verb = (verb or function_name).strip()
mark = f"{_RED}✗{_RST}" if suffix is not None else (emoji or "●")
line = f"{mark} {_BOLD}{verb[:1].upper()}{verb[1:]}{_RST}"
if detail:
line += f" {detail}"
if index == len(rows) - 1 and duration >= 0.1:
line += f" {_DIM}{duration:.1f}s{_RST}"
if suffix:
line += f"{_RED}{suffix}{_RST}"
out.append(line)
return "\n".join(out)
_ACTIVE = False # one CLI per process; read by printers that get no CLI handle (gateway inline diffs)
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
_DIFF_FILE_RE = re.compile(r"^a/(.+?) → b/(.+)$")
def set_active(enabled: bool) -> None:
global _ACTIVE
_ACTIVE = bool(enabled)
def _display_path(path: str) -> str:
path = path[1:] if path.startswith("//") else path
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
try:
rel = os.path.relpath(path, cwd) if os.path.isabs(path) else path
except ValueError:
return path
return path if rel.startswith("..") else rel
def gutter_print(print_fn):
"""Wrap a line printer so a diff hangs under its tool row behind a dim ``│``: the ``review
diff`` header is dropped and ``a/<abs> → b/<abs>`` collapses to one cwd-relative path."""
header = _ANSI_RE.sub("", t("display.diff.review_header")).strip()
def _print(text):
for line in str(text).split("\n"):
plain = _ANSI_RE.sub("", line).strip()
if plain == header:
continue
match = _DIFF_FILE_RE.match(plain)
if match:
old, new = _display_path(match.group(1)), _display_path(match.group(2))
line = f"{_BOLD}{new if old == new else f'{old} → {new}'}{_RST}"
print_fn(f"{_DIM}│{_RST} {line}" if plain else f"{_DIM}│{_RST}")
return _print
def diff_printer(print_fn):
"""``print_fn`` with the gutter when the glyph layout is active in this process."""
return gutter_print(print_fn) if _ACTIVE else print_fn
def turn_footer(summary: str) -> str:
"""``─ 12.4s · read 3 files · edited 1 file +4 -0 · 7:02 AM`` closing each turn."""
body = summary.lstrip("⋯ ").strip()
clock = datetime.now().strftime("%-I:%M %p") if os.name != "nt" else datetime.now().strftime("%I:%M %p")
return f"{_DIM}─ {body} · {clock}{_RST}" if body else f"{_DIM}─ {clock}{_RST}"
# ── live region (prompt_toolkit fragments, redrawn every frame) ─────────────────────────────
def _cwd_label() -> str:
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
home = os.path.expanduser("~")
short = "~" + cwd[len(home):] if cwd == home or cwd.startswith(home + os.sep) else cwd
from hermes_cli.status_bar_git import current_git_branch
branch = current_git_branch(cwd)
return f"{short} ({branch})" if branch else short
def activity_text(cli) -> str:
"""What the agent is doing: the classic spinner's text (kaomoji face or tool emoji included)."""
if getattr(cli, "_command_running", False):
return str(getattr(cli, "_command_status", "") or "")
text = str(getattr(cli, "_spinner_text", "") or "").strip()
return text or (t("cli.modern.working") if getattr(cli, "_agent_running", False) else "")
def _turn_elapsed(cli) -> str:
started = getattr(cli, "_prompt_start_time", None)
if started is None:
return ""
from cli import format_duration_compact
return format_duration_compact(max(0.0, time.time() - float(started)))
def rule_style() -> str:
"""Composer rules are quiet chrome: the skin's session border, not the accent."""
return f"fg:{_skin_hex('session_border', '#8B8682')}"
def _no_bg(frags: list) -> list:
"""The footer sits on the terminal background, not the classic status bar's fill."""
return [(f"{style} bg:default".strip(), text) for style, text in frags]
def _fit(cli, frags: list, width: int) -> list:
used = sum(cli._status_bar_display_width(text) for _, text in frags)
if used <= width:
return frags
plain = "".join(text for _, text in frags)
return [(rule_style(), cli._trim_status_bar_text(plain, width))]
def top_rule_fragments(cli) -> list:
"""``── ⠹ Running tests · 14s ─────────── calc helpers ─``: live status left, session title right."""
width = max(10, cli._get_tui_terminal_width())
rule, accent = rule_style(), f"fg:{_skin_hex('ui_accent', '#FFBF00')} bold"
busy = getattr(cli, "_agent_running", False) or getattr(cli, "_command_running", False)
left: list = []
if busy:
frame = _SPIN[int(time.monotonic() * 10) % len(_SPIN)]
left = [(rule, "── "), (accent, frame + " "), ("", activity_text(cli))]
# Classic's spinner suffix: turn time and the streamed-token count (``↓ 547 tok``).
for extra in (_turn_elapsed(cli), cli._spinner_token_flow()):
if extra:
left.append(("class:status-bar-dim bg:default", f" · {extra}"))
left.append((rule, " "))
title = cli._get_status_bar_session_title()
right = [(rule, " "), (f"fg:{_skin_hex('ui_label', '#DAA520')}", title), (rule, " ─")] if title else []
used = sum(cli._status_bar_display_width(text) for _, text in left + right)
if used >= width:
right, used = [], sum(cli._status_bar_display_width(text) for _, text in left)
return _fit(cli, left + [(rule, "─" * max(0, width - used))] + right, width)
def hint_pairs(cli) -> list[tuple[str, str]]:
"""Keys that do something right now, for the footer's right half."""
if any(getattr(cli, attr, None) for attr in (
"_approval_state", "_clarify_state", "_sudo_state", "_secret_state", "_connection_state",
"_slash_confirm_state", "_model_picker_state", "_command_palette_state")):
return []
if getattr(cli, "_agent_running", False):
enter = {"steer": "steer", "queue": "queue"}.get(getattr(cli, "busy_input_mode", ""), "interrupt")
pairs = [("enter", t(f"cli.modern.hint_{enter}"))]
if enter != "queue":
pairs.append(("tab", t("cli.modern.hint_queue")))
return pairs + [("ctrl+c", t("cli.modern.hint_stop"))]
return [("/", t("cli.modern.hint_commands")), ("@", t("cli.modern.hint_files")), ("!", t("cli.modern.hint_shell"))]
def status_line_fragments(cli) -> list:
"""Footer under the composer: model · context gauge · cwd (branch) left, live key hints right.
An explicit ``display.status_bar.fields`` list keeps the user's chosen segments on the left."""
return _render_safely(_status_line_fragments, cli)
def _render_safely(render, cli) -> list:
"""Boundary for prompt_toolkit render callbacks: a bad frame degrades to a bare line."""
try:
return render(cli)
except Exception: # health: allow BLE001 -- a render callback must never take down the app frame
logger.debug("%s failed", render.__name__, exc_info=True)
return [("", f" {getattr(cli, 'model', '') or 'Hermes'}")]
def _status_line_fragments(cli) -> list:
"""The classic bar's segments (same fields, same width tiers, same ``display.status_bar.fields``
filter), then cwd (branch) and the live key hints when there is room."""
width = max(10, cli._get_tui_terminal_width())
dim, strong = "class:status-bar-dim", "class:status-bar-strong"
snapshot = cli._get_status_bar_snapshot()
field_set = cli._get_status_bar_field_set()
def _on(name):
return field_set is None or name in field_set
left: list = []
for seg in cli._status_bar_segments(snapshot, width, field_set, cli._is_session_yolo_active(), styled=True):
if left:
left.append((dim, " · "))
left.extend(seg)
stash = cli._prompt_stash.indicator() if _on("stash") and hasattr(cli, "_prompt_stash") else ""
if stash: # a parked draft is never silently forgotten
left += [(dim, " · "), (strong, stash)]
battery = snapshot.get("battery_label") or "" # only set while /battery is on
if battery and _on("battery"):
left[0:0] = [("", " "), (cli._battery_status_style(snapshot.get("battery_category", "dim")), battery),
(dim, " ·")]
vim = cli._vim_mode_label()
if vim:
left += [(dim, " · "), (strong, vim)]
right: list = []
for key, text in hint_pairs(cli) if width >= 76 else []:
if right:
right.append((dim, " · "))
right += [(strong, key), (dim, f" {text}")]
used_left = sum(cli._status_bar_display_width(text) for _, text in left)
used_right = sum(cli._status_bar_display_width(text) for _, text in right)
room = width - used_left - used_right - 6
if field_set is None and room >= 12: # cwd takes whatever the hints leave, trimmed from the left
cwd = _cwd_label()
cwd = cwd if len(cwd) <= room else "…" + cwd[-(room - 1):]
left += [(dim, " · "), (dim, cwd)]
used_left += 3 + cli._status_bar_display_width(cwd)
if right and used_left + used_right + 2 > width:
right, used_right = [], 0
pad = [("", " " * max(1, width - used_left - used_right - 1))] if right else []
return _no_bg(_fit(cli, left + pad + right + ([("", " ")] if right else []), width))
def modern_input_rules(cli, kb):
"""The composer's rules for this layout (the top one carries live status), plus Tab-to-queue."""
from prompt_toolkit.filters import Condition
from prompt_toolkit.layout import FormattedTextControl, Window
top = Window(FormattedTextControl(lambda: _render_safely(top_rule_fragments, cli)),
height=lambda: cli._tui_input_rule_height("top"), wrap_lines=False)
bottom = Window(char="─", height=lambda: cli._tui_input_rule_height("bottom"), style=rule_style)
def _can_queue() -> bool:
from prompt_toolkit.application import get_app
from cli import _looks_like_slash_command
from hermes_cli.cli_subagent_monitor import modal_prompt_active
buf = get_app().current_buffer
text = buf.text.strip()
return bool(getattr(cli, "_agent_running", False) and text and buf.complete_state is None
and not _looks_like_slash_command(text) and not text.startswith("!")
and not modal_prompt_active(cli))
@kb.add("tab", filter=Condition(_can_queue), eager=True)
def _queue_while_busy(event):
# Not _tui_handle_enter: its paste/continuation rules would turn this key into a newline.
buf = event.app.current_buffer
text, images = buf.text.strip(), list(cli._attached_images)
cli._attached_images.clear()
cli._tui_enter_while_busy(text, images, (text, images) if images else text, mode="queue")
cli._inline_pastes(buf)
buf.reset(append_to_history=True)
event.app.invalidate()
return top, bottom
+28
View File
@@ -0,0 +1,28 @@
"""Bordered overlay panels (approval, clarify, sudo, pickers) for the classic CLI's live region."""
from hermes_cli.cli_render import _append_blank_panel_line, _append_panel_line
class _Panel:
"""Fragment accumulator for one bordered overlay panel (``(style, text)`` tuples)."""
def __init__(self, border: str, box_width: int, title: str = "", title_style: str = ""):
self.lines, self.border, self.width = [], border, box_width
self._row, self._blank = _append_panel_line, _append_blank_panel_line
if title:
# Title inlined into the top rule: ``╭─ Title ───╮``.
self.lines.append((border, "╭─ "))
self.lines.append((title_style, title))
self.lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n"))
else:
self.lines.append((border, "╭" + ("─" * box_width) + "╮\n"))
def row(self, style: str, text: str) -> None:
self._row(self.lines, self.border, style, text, self.width)
def blank(self) -> None:
self._blank(self.lines, self.border, self.width)
def close(self) -> list:
self.lines.append((self.border, "╰" + ("─" * self.width) + "╯\n"))
return self.lines
+2
View File
@@ -90,6 +90,8 @@ class CLIProcessNotificationsMixin:
user_input = user_input.text
is_seeded_query = isinstance(user_input, _SeededQueryMessage)
if is_seeded_query:
from hermes_cli.gateway_tools_notify import arm_adopted
arm_adopted(self, user_input)
user_input = (user_input.text, user_input.images) if user_input.images else user_input.text
return user_input, is_voice_input, is_seeded_query
+4 -2
View File
@@ -958,9 +958,11 @@ def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46,
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "", *, keep_ws: bool = False) -> list[str]:
"""Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews)."""
"""Wrap panel text; ``keep_ws`` preserves whitespace (command/detail previews). Each source
line wraps on its own: a newline inside a row would break the panel's right border."""
kw = dict(replace_whitespace=False, drop_whitespace=False) if keep_ws else dict(break_long_words=False, break_on_hyphens=False)
wrapped = textwrap.wrap(text, width=max(8, width), subsequent_indent=subsequent_indent, **kw)
wrapped = [row for part in (text.expandtabs(4).splitlines() if keep_ws else [text])
for row in (textwrap.wrap(part, width=max(8, width), subsequent_indent=subsequent_indent, **kw) or [""])]
return wrapped or [""]
+9 -2
View File
@@ -86,9 +86,16 @@ def _prompt_text(item) -> str:
def queued_prompts(cli) -> list[str]:
"""One-line previews of the turns waiting in ``_pending_input``, oldest first."""
from hermes_cli.gateway_tools_notify import gateway_store
owned: list = []
store = gateway_store(cli) if getattr(cli, "session_id", None) else None
if store is not None:
# The gateway's FIFO runs first (#134889 N5): its queued admissions as the owner last published them.
from hermes_cli.gateway_queue import published_queue
owned = published_queue(cli, store)
pending = getattr(cli, "_pending_input", None)
if pending is None:
return []
return [text for text in map(_prompt_text, owned) if text]
with pending.mutex:
items = list(pending.queue)
return [text for text in map(_prompt_text, items) if text]
return [text for text in map(_prompt_text, owned + items) if text]
+12 -4
View File
@@ -539,14 +539,14 @@ class CLIStatusBarMixin:
def _agent_spacer_height(self, width: Optional[int] = None) -> int:
"""Spacer height above the status bar while the agent runs."""
if not getattr(self, "_agent_running", False):
if not getattr(self, "_agent_running", False) or getattr(self, "_modern_layout", False):
return 0
return 0 if self._use_minimal_tui_chrome(width=width) else 1
def _spinner_widget_height(self, width: Optional[int] = None) -> int:
"""Visible height of the spinner/status line above the status bar."""
spinner_line = self._render_spinner_text()
if not spinner_line or self._use_minimal_tui_chrome(width=width):
if not spinner_line or self._use_minimal_tui_chrome(width=width) or getattr(self, "_modern_layout", False):
return 0
width = width or self._get_tui_terminal_width()
if width and width > 10:
@@ -597,7 +597,9 @@ class CLIStatusBarMixin:
if getattr(self, "tool_progress_mode", "all") == "off":
return False
agent = getattr(self, "agent", None)
if agent is not None and getattr(agent, "quiet_mode", False):
# The interactive agent itself runs quiet unless --verbose, so in the classic layout this
# line only shows under --verbose; the glyph layout closes every interactive turn with it.
if agent is not None and getattr(agent, "quiet_mode", False) and not getattr(self, "_modern_layout", False):
return False
return bool(getattr(self, "_interactive_turn", False))
@@ -638,7 +640,10 @@ class CLIStatusBarMixin:
try:
started = getattr(self, "_turn_summary_start", 0.0) or 0.0
line = collector.render(max(0.0, time.monotonic() - started) if started else 0.0)
if line:
if line and getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import turn_footer
_cprint(turn_footer(line))
elif line:
_cprint(f" {_D}{line}{_RST}")
except Exception:
logger.debug("Turn summary render failed", exc_info=True)
@@ -1131,6 +1136,9 @@ class CLIStatusBarMixin:
or getattr(self, "_model_picker_state", None)
or getattr(self, "_command_palette_state", None)):
return []
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import status_line_fragments
return status_line_fragments(self)
try:
snapshot = self._get_status_bar_snapshot()
# prompt_toolkit's own width: shutil's can be stale (esp. over SSH) and an overflow
+61 -13
View File
@@ -185,8 +185,10 @@ class CLIStreamMixin:
f" [dim]{datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}[/]"
if getattr(self, "show_timestamps", False) else "")
lines = user_input.split("\n")
from hermes_cli.cli_modern_layout import is_modern, prompt_glyph
mark = _escape(prompt_glyph()) if is_modern(self) else "●"
if len(lines) <= 1:
return f"[bold {_accent_hex()}]●[/] [bold]{_escape(user_input)}[/]{ts_suffix}"
return f"[bold {_accent_hex()}]{mark}[/] [bold]{_escape(user_input)}[/]{ts_suffix}"
first_lines = max(1, int(getattr(self, "user_message_preview_first_lines", 2)))
last_lines = max(0, int(getattr(self, "user_message_preview_last_lines", 2)))
@@ -198,7 +200,7 @@ class CLIStreamMixin:
hidden_middle_count = 0
tail = []
preview_lines = [f"[bold {_accent_hex()}]●[/] [bold]{_escape(head[0])}[/]{ts_suffix}"]
preview_lines = [f"[bold {_accent_hex()}]{mark}[/] [bold]{_escape(head[0])}[/]{ts_suffix}"]
preview_lines.extend(f"[bold]{_escape(line)}[/]" for line in head[1:])
if hidden_middle_count > 0:
key = "cli.stream.preview_more_lines_one" if hidden_middle_count == 1 else "cli.stream.preview_more_lines_other"
@@ -228,17 +230,26 @@ class CLIStreamMixin:
def _print_user_message_preview(self, user_input: str) -> None:
"""Render a user message using the normal chat scrollback style."""
from cli import ChatConsole, _accent_hex
from cli import ChatConsole, _accent_hex, _cprint
from tools.process_registry_notifications import TimelineNotification
if isinstance(user_input, TimelineNotification):
from gateway.warning_notifications import render_notification
render_notification(lambda: ChatConsole().print(f"[dim]◈ {_escape(user_input.display_text)}[/dim]"),
platform="cli", diagnostic=user_input.notification_category == "diagnostic")
return
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
modern = getattr(self, "_modern_layout", False)
if modern:
from hermes_cli.cli_modern_layout import separator
_cprint("\n" + separator(True))
else:
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
text = str(user_input or "")
if "\n" in text:
ChatConsole().print(self._format_submitted_user_message_preview(text))
# soft_wrap in the glyph layout: the terminal wraps a long prompt, so a resize re-flows it.
ChatConsole().print(self._format_submitted_user_message_preview(text), soft_wrap=modern)
elif modern:
from hermes_cli.cli_modern_layout import user_echo_markup
ChatConsole().print(user_echo_markup(text, _accent_hex()), soft_wrap=True)
else:
ChatConsole().print(f"[bold {_accent_hex()}]●[/] [bold]{_escape(text)}[/]")
@@ -255,6 +266,22 @@ class CLIStreamMixin:
self._reasoning_shown_this_turn = True
if getattr(self, "_stream_box_opened", False):
return
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import reasoning_text_line, separator
if not getattr(self, "_reasoning_box_opened", False):
self._reasoning_box_opened, self._reasoning_started_at = True, time.monotonic()
self._reasoning_buf, self._reasoning_full_text = "", ""
_cprint("\n" + separator(False))
self._reasoning_full_text = getattr(self, "_reasoning_full_text", "") + text
self._reasoning_buf = getattr(self, "_reasoning_buf", "") + text
# Live, as classic: complete lines now, long partial lines force-flushed.
while "\n" in self._reasoning_buf:
line, self._reasoning_buf = self._reasoning_buf.split("\n", 1)
_cprint(reasoning_text_line(line))
if len(self._reasoning_buf) > 80:
_cprint(reasoning_text_line(self._reasoning_buf))
self._reasoning_buf = ""
return
if not getattr(self, "_reasoning_box_opened", False):
self._reasoning_box_opened = True
w = self._scrollback_box_width()
@@ -296,6 +323,17 @@ class CLIStreamMixin:
from cli import _DIM, _RST, _cprint
if not getattr(self, "_reasoning_box_opened", False):
return
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import reasoning_text_line, thought_line
if getattr(self, "_reasoning_buf", ""):
_cprint(reasoning_text_line(self._reasoning_buf))
started = getattr(self, "_reasoning_started_at", 0.0)
_cprint(thought_line(time.monotonic() - started if started else None,
getattr(self, "_reasoning_full_text", "")))
self._reasoning_buf, self._reasoning_full_text, self._reasoning_box_opened = "", "", False
if not getattr(self, "_stream_box_live", False):
self._release_held_status_lines()
return
buf = getattr(self, "_reasoning_buf", "")
if buf:
_cprint(f"{_DIM}{buf}{_RST}")
@@ -451,9 +489,14 @@ class CLIStreamMixin:
self._stream_text_ansi = ""
if self.show_timestamps:
label = f"{label} {datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))}"
w = self._scrollback_box_width()
fill = w - 2 - HermesCLI._status_bar_display_width(label)
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import answer_label
_cprint(answer_label(datetime.now().strftime(getattr(self, 'timestamp_format', '%H:%M'))
if self.show_timestamps else ""))
else:
w = self._scrollback_box_width()
fill = w - 2 - HermesCLI._status_bar_display_width(label)
_cprint(f"\n{_ACCENT}╭─{label}{'─' * max(fill - 1, 0)}╮{_RST}")
# Turn-level record of what actually reached the screen; survives _reset_stream_state at
# tool-call boundaries so an interrupted reply isn't re-rendered as a Panel (#65666).
@@ -519,7 +562,7 @@ class CLIStreamMixin:
line = _strip_markdown_syntax(self._stream_buf) if self.final_response_markdown == "strip" else self._stream_buf
self._emit_stream_line(line)
self._stream_buf = ""
if self._stream_box_opened and getattr(self, "_stream_box_live", False):
if self._stream_box_opened and getattr(self, "_stream_box_live", False) and not getattr(self, "_modern_layout", False):
w = self._scrollback_box_width()
_cprint(f"{_ACCENT}╰{'─' * (w - 2)}╯{_RST}")
self._stream_box_live = False
@@ -661,6 +704,10 @@ class CLIStreamMixin:
announced.add(tool_name)
from agent.display import bridge_generating_phrase, get_tool_emoji
what = bridge_generating_phrase(tool_name) or tool_name
if getattr(self, "_modern_layout", False): # the composer rule shows it; scrollback stays quiet
self._spinner_text = f"{get_tool_emoji(tool_name, default='⚡')} {t('cli.modern.preparing', what=what)}"
self._invalidate()
return
_cprint(f" ┊ {t('cli.stream.tool_preparing', emoji=get_tool_emoji(tool_name, default='⚡'), what=what)}")
def _on_tool_progress(self, event_type: str, function_name: str | None = None, preview: str | None = None, function_args: dict | None = None, **kwargs):
@@ -731,9 +778,9 @@ class CLIStreamMixin:
return
self._last_scrollback_tool = function_name
try:
from agent.display import get_cute_tool_message
line = get_cute_tool_message(function_name, stored_args, duration, result=kwargs.get("result"))
_cprint(f" {line}")
from hermes_cli.cli_modern_layout import completed_tool_line
_cprint(completed_tool_line(self, function_name, stored_args, duration, kwargs.get("result"),
kwargs.get("is_error", False)))
except Exception:
pass
# One-time /verbose hint on the first long tool in the noisiest mode; latched
@@ -800,8 +847,9 @@ class CLIStreamMixin:
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
try:
from agent.display import render_edit_diff_with_delta
from hermes_cli.cli_modern_layout import diff_printer
render_edit_diff_with_delta(
function_name, function_result, function_args=function_args, snapshot=snapshot,
print_fn=_cprint)
print_fn=diff_printer(_cprint))
except Exception:
logger.debug("Edit diff preview failed for %s", function_name, exc_info=True)
+81 -33
View File
@@ -42,6 +42,7 @@ class SubagentMonitor:
self.app = None
self.opening = False
self.collapsed = False
self.detail = False # the monitor's detail view is open (its tick fetches the gateway tail)
@property
def has_rows(self):
@@ -65,18 +66,23 @@ class SubagentMonitor:
row = self.selected
return row if row and row.get('kind') == 'process' else None
def subagents(self):
"""The subagent backend: the gateway session's view when its children run there (CLI-12),
else this process's own registry; both answer the same ``subagent_*`` calls."""
from hermes_cli.gateway_tools import LocalSubagents, process_view
view = process_view()
if view is not None:
return view
if getattr(self, '_local', None) is None:
self._local = LocalSubagents(self.cli)
return self._local
def refresh(self, now=None):
from tools.delegate_tool_registry import _list_payload, list_active_subagents
now = time.time() if now is None else now
parent = getattr(self.cli, 'agent', None)
entries = _list_payload(parent)['subagents'] if parent is not None else []
# The scoped control-plane snapshot supplies authority and transcript paths;
# its matching public lifecycle record supplies the latest observed tool.
activity = {r['subagent_id']: r for r in list_active_subagents()} if entries else {}
entries = self.subagents().subagent_rows(busy=bool(getattr(self.cli, '_agent_running', False)))
for row in entries:
live = activity.get(row['subagent_id'], {})
row['elapsed'] = max(0, int(now - live.get('started_at', now)))
row['last_tool'] = live.get('last_tool') or ''
row['elapsed'] = max(0, int(now - (row.get('started_at') or now)))
row['last_tool'] = row.get('last_tool') or ''
row['key'] = row['subagent_id']
row.pop('running_seconds', None)
processes = procs.process_rows(now)
@@ -108,17 +114,33 @@ class SubagentMonitor:
# invalidate call, which reads the loop more than once.
_run_on_app_loop(app, app.invalidate)
def poll_soon(self):
"""Let the spinner thread's next tick poll the gateway, instead of the caller blocking the
UI loop on it."""
self._last_poll = 0
def tick(self):
now = time.monotonic()
if now - self._last_poll < 1:
return
self._last_poll = now
if self.refresh():
changed = self.refresh()
if self._poll_tail() or changed:
if self.app is not None:
self.invalidate()
else:
self.cli._invalidate()
def _poll_tail(self):
"""Off the render path (the spinner thread's tick): refresh the gateway tail the open detail
view paints from the cache. True when the text changed."""
row = self.selected
if not self.detail or row is None or row.get('kind') == 'process':
return False
backend = self.subagents()
before = backend.subagent_tail(row['subagent_id'])
return backend.subagent_tail(row['subagent_id'], fetch=True) != before
def select(self, delta):
roster = self.roster
if roster:
@@ -131,8 +153,7 @@ class SubagentMonitor:
if action != 'stop':
return {'error': t('cli.subagents.cannot_steer_process')}
return procs.kill(target)
from tools.delegate_tool_registry import _handle_control_action
return json.loads(_handle_control_action(action, target, message, getattr(self.cli, 'agent', None)))
return self.subagents().subagent_control(action, target, message)
def _counts(self, *, session=True):
"""Count fragment: ``2 live``, ``1 proc``, ``2 live · 3 procs``; the collapsed heading
@@ -260,10 +281,19 @@ def read_tail(path):
return t('cli.subagents.transcript_unavailable')
def subagent_tail(monitor, row):
"""The child's transcript tail from the monitor's backend (the gateway's is fetched and filtered by
the monitor's tick, never here: the detail view calls this every frame)."""
text = monitor.subagents().subagent_tail(row['subagent_id'])
return t('cli.subagents.transcript_unavailable') if text is None else text
def modal_prompt_active(cli):
"""A prompt owns the composer (its text is an answer or a secret, never a chat message):
every composer shortcut stays off while one is open."""
return any(getattr(cli, name, None) for name in (
'_clarify_state', '_approval_state', '_slash_confirm_state', '_sudo_state',
'_secret_state', '_model_picker_state', '_command_palette_state'))
'_secret_state', '_connection_state', '_model_picker_state', '_command_palette_state'))
def build_monitor_application(monitor, **kwargs):
@@ -275,7 +305,7 @@ def build_monitor_application(monitor, **kwargs):
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.widgets import TextArea
state = {'detail': False, 'steering': False, 'confirm': False, 'notice': ''}
state = {'steering': False, 'confirm': False, 'notice': ''}
steer = TextArea(height=1, prompt=t('cli.subagents.steer_prompt'), multiline=False)
tail = TextArea(read_only=True, scrollbar=True, wrap_lines=True)
@@ -312,7 +342,7 @@ def build_monitor_application(monitor, **kwargs):
elif row.get('kind') == 'process':
text = procs.process_tail(row['id'])
else:
text = read_tail(row.get('live_transcript'))
text = subagent_tail(monitor, row)
if text != tail.text:
following = tail.buffer.cursor_position == len(tail.text)
position = tail.buffer.cursor_position
@@ -322,7 +352,7 @@ def build_monitor_application(monitor, **kwargs):
def header():
row = monitor.selected
title = f"{monitor._title()} · {monitor._counts(session=False)}"
if state['detail'] and row:
if monitor.detail and row:
title += f" · {monitor._key(row)} · {row.get('goal') or row.get('command') or ''}"
return [('class:subagent-dock.heading', _clip(title, app.output.get_size().columns))]
@@ -337,14 +367,31 @@ def build_monitor_application(monitor, **kwargs):
return t('cli.subagents.footer_steering_compact' if narrow else 'cli.subagents.footer_steering')
steer = '' if process else t('cli.subagents.footer_steer_key')
if narrow:
return (t('cli.subagents.footer_detail_compact', steer=steer) if state['detail']
return (t('cli.subagents.footer_detail_compact', steer=steer) if monitor.detail
else t('cli.subagents.footer_roster_compact'))
steer = '' if process else t('cli.subagents.footer_steer_key_long')
return t('cli.subagents.footer_detail' if state['detail'] else 'cli.subagents.footer_roster', steer=steer)
return t('cli.subagents.footer_detail' if monitor.detail else 'cli.subagents.footer_roster', steer=steer)
def run_control(action, message, target):
"""Steer/stop off the UI loop: a stalled gateway call (up to its timeout) must not freeze the
monitor's repaint, Esc or Ctrl+C. The answer lands in the notice line when it arrives."""
import asyncio
state['notice'] = t('cli.subagents.control_pending')
def done(future):
try:
result = future.result()
except Exception as exc: # health: allow BLE001 -- a control failure is shown, never raised into the UI loop
result = {'error': str(exc)}
state['notice'] = result.get('error') or result.get('note') or str(result)
app.invalidate()
asyncio.get_running_loop().run_in_executor(
None, lambda: monitor.control(action, message, target=target)).add_done_callback(done)
kb = KeyBindings()
normal = Condition(lambda: not state['steering'] and not state['confirm'])
listing = normal & Condition(lambda: not state['detail'])
listing = normal & Condition(lambda: not monitor.detail)
@kb.add('up', filter=listing)
def up(event):
@@ -357,7 +404,8 @@ def build_monitor_application(monitor, **kwargs):
@kb.add('enter', filter=listing)
def detail(event):
if monitor.selected:
state['detail'] = True
monitor.detail = True
monitor.poll_soon() # the spinner thread's next tick fetches the tail
update_tail()
app.layout.focus(tail)
@@ -372,11 +420,10 @@ def build_monitor_application(monitor, **kwargs):
def send_steer(event):
if not steer.text.strip():
return
result = monitor.control('steer', steer.text, target=state['target'])
state['notice'] = result.get('error') or result.get('note') or str(result)
run_control('steer', steer.text, state['target'])
steer.text = ''
state['steering'] = False
app.layout.focus(tail if state['detail'] else roster)
app.layout.focus(tail if monitor.detail else roster)
@kb.add('x', filter=normal)
def stop(event):
@@ -386,17 +433,16 @@ def build_monitor_application(monitor, **kwargs):
@kb.add('y', filter=Condition(lambda: state['confirm']))
def confirm(event):
result = monitor.control('stop', target=state['target'])
state['notice'] = result.get('error') or result.get('note') or str(result)
run_control('stop', None, state['target'])
state['confirm'] = False
@kb.add('escape', eager=True)
def back(event):
if state['steering'] or state['confirm']:
state['steering'] = state['confirm'] = False
app.layout.focus(tail if state['detail'] else roster)
elif state['detail']:
state['detail'] = False
app.layout.focus(tail if monitor.detail else roster)
elif monitor.detail:
monitor.detail = False
app.layout.focus(roster)
else:
app.exit()
@@ -410,8 +456,8 @@ def build_monitor_application(monitor, **kwargs):
layout = Layout(HSplit([
Window(FormattedTextControl(header), height=1),
ConditionalContainer(roster, filter=Condition(lambda: not state['detail'])),
ConditionalContainer(tail, filter=Condition(lambda: state['detail'])),
ConditionalContainer(roster, filter=Condition(lambda: not monitor.detail)),
ConditionalContainer(tail, filter=Condition(lambda: monitor.detail)),
ConditionalContainer(steer, filter=Condition(lambda: state['steering'])),
Window(FormattedTextControl(lambda: _clip(state['notice'], app.output.get_size().columns)), height=1),
Window(FormattedTextControl(footer), height=1),
@@ -421,7 +467,7 @@ def build_monitor_application(monitor, **kwargs):
# first frame if a prompt won the race with in_terminal() acquisition.
if modal_prompt_active(monitor.cli) and not app.is_done:
app.exit()
elif state['detail']:
elif monitor.detail:
update_tail()
from prompt_toolkit.styles import Style
@@ -443,12 +489,14 @@ def open_monitor(cli):
async def run():
try:
async with in_terminal():
monitor.refresh()
# Paint the roster the dock's tick already holds; refreshing here would block the UI
# loop on the gateway's subagent.list. The spinner thread's next tick re-reads it.
monitor.poll_soon()
monitor.app = build_monitor_application(monitor)
await monitor.app.run_async()
finally:
monitor.app = None
monitor.opening = False
monitor.detail = monitor.opening = False
cli._invalidate()
asyncio.get_running_loop().create_task(run())
+16 -32
View File
@@ -39,6 +39,7 @@ from prompt_toolkit.widgets import TextArea
from typing import Optional
from hermes_cli.cli_footer_split import FooterSplit
from hermes_cli.cli_panel import _Panel
from hermes_cli.cli_tui_wrap import _prefix_wrapped_rows, _wrap_rows
# Rows below an overlay panel taken by spinner/tool-progress, status bar, input, separators and
@@ -85,30 +86,6 @@ def _term_rows() -> int:
return shutil.get_terminal_size((100, 24)).lines
class _Panel:
"""Fragment accumulator for one bordered overlay panel (``(style, text)`` tuples)."""
def __init__(self, border: str, box_width: int, title: str = "", title_style: str = ""):
from cli import _append_blank_panel_line, _append_panel_line
self.lines, self.border, self.width = [], border, box_width
self._row, self._blank = _append_panel_line, _append_blank_panel_line
if title:
# Title inlined into the top rule: ``╭─ Title ───╮``.
self.lines.append((border, "╭─ "))
self.lines.append((title_style, title))
self.lines.append((border, " " + ("─" * max(0, box_width - len(title) - 3)) + "╮\n"))
else:
self.lines.append((border, "╭" + ("─" * box_width) + "╮\n"))
def row(self, style: str, text: str) -> None:
self._row(self.lines, self.border, style, text, self.width)
def blank(self) -> None:
self._blank(self.lines, self.border, self.width)
def close(self) -> list:
self.lines.append((self.border, "╰" + ("─" * self.width) + "╯\n"))
return self.lines
class CLITuiMixin:
@@ -420,6 +397,8 @@ class CLITuiMixin:
input_rule_bot,
voice_status_bar,
completions_menu]
if getattr(self, "_modern_layout", False): # footer under the composer and its completions
ordered.append(ordered.pop(ordered.index(status_bar)))
return [item for item in ordered if item is not None]
def _tui_spinner_loop(self):
@@ -801,6 +780,8 @@ class CLITuiMixin:
if self._command_running:
return f"{self._command_spinner_frame()} {self._command_status or t('cli.tui.placeholder_processing_command')}"
if self._agent_running:
if getattr(self, "_modern_layout", False):
return t(f"cli.modern.placeholder_busy_{self.busy_input_mode}")
return t("cli.tui.placeholder_busy")
if self._voice_mode:
return t("cli.tui.placeholder_voice_idle", shortcut=self._voice_record_key_label())
@@ -1483,16 +1464,14 @@ class CLITuiMixin:
event.app.invalidate()
return True
def _tui_enter_while_busy(self, text: str, images: list, payload) -> None:
def _tui_enter_while_busy(self, text: str, images: list, payload, mode: str | None = None) -> None:
"""Route a submission typed while the agent runs, per ``busy_input_mode``.
steer → agent.steer(text) mid-run (images can't ride along and a missing/rejecting
steer() falls back to queue so nothing is lost). interrupt → agent.redirect() when the
agent supports active-turn redirect, else the legacy interrupt queue (older agents,
multimodal follow-ups, or a turn that finished in the race). queue → next turn.
"""
steer → agent.steer(text) mid-run (images can't ride along; a missing/rejecting steer() falls back
to queue). interrupt → agent.redirect() when supported, else the legacy interrupt queue. queue →
next turn: text-only on the gateway's session is a ``/queue`` FIFO admission (#134889 N5)."""
from cli import CLI_CONFIG, _ACCENT, _DIM, _RST, _cprint, _hermes_home
_effective_mode = self.busy_input_mode
_effective_mode = mode or self.busy_input_mode
redirected = False
if _effective_mode == "steer":
if images or not text:
@@ -1512,7 +1491,9 @@ class CLITuiMixin:
_cprint(f" {_ACCENT}{t('cli.tui.steered', preview=preview)}{_RST}")
else:
_effective_mode = "queue"
if _effective_mode == "queue":
if _effective_mode == "queue" and text and not images and self._gateway_queue_store() is not None:
self._queue_enqueue(text)
elif _effective_mode == "queue":
self._pending_input.put(payload)
preview = text if text else _tn("cli.tui.images_attached_preview", len(images))
preview = preview[:80] + ('...' if len(preview) > 80 else '')
@@ -2145,6 +2126,9 @@ class CLITuiMixin:
input_rule_bot = Window(
char='─', height=lambda: cli_ref._tui_input_rule_height("bottom"), style='class:input-rule',
)
if getattr(self, "_modern_layout", False):
from hermes_cli.cli_modern_layout import modern_input_rules
input_rule_top, input_rule_bot = modern_input_rules(self, kb)
image_bar = Window(
content=FormattedTextControl(self._tui_image_bar_fragments),
height=Condition(lambda: bool(cli_ref._attached_images)))
+24 -1
View File
@@ -30,15 +30,24 @@ class CLITuiRuntimeMixin:
def _tui_process_loop(self):
"""REPL worker thread: drain ``_pending_input``, run idle housekeeping, dispatch each input."""
held = None
while not self._should_exit:
try:
if held is not None:
held, user_input = self._tui_hold_step(held)
if user_input is not None:
self._tui_process_one_input(user_input)
continue
try:
user_input = self._pending_input.get(timeout=0.1)
except queue.Empty:
if not self._agent_running:
self._tui_idle_tick()
continue
self._tui_process_one_input(user_input)
from hermes_cli.gateway_queue import OwnQueueHold
held = OwnQueueHold.begin(self, user_input)
if held is None:
self._tui_process_one_input(user_input)
except Exception as e:
if isinstance(e, OSError) and e.errno == errno.EIO:
self._mark_terminal_io_broken("process_loop")
@@ -46,6 +55,20 @@ class CLITuiRuntimeMixin:
continue
logger.warning("process_loop unhandled error (msg may be lost): %s", e)
def _tui_hold_step(self, hold):
"""One step of holding a local input behind this viewer's own ``/queue`` admission (#134889 N5):
``(hold or None, input to run now or None)``. A turn the owner runs now goes first; the held
input runs once that admission was taken, left the queue, or the bounded wait ran out."""
with suppress(Exception):
self._drain_process_notifications("cli-idle") # adopts the admission on its message.start
item = hold.take_gateway_turn(self._pending_input)
if item is not None:
return hold, item
if hold.done():
return None, hold.item
time.sleep(0.05)
return hold, None
def _tui_idle_tick(self):
"""Idle housekeeping between inputs (agent not running)."""
self._check_config_mcp_changes() # auto-reload MCP on mcp_servers change
+3 -3
View File
@@ -850,9 +850,9 @@ DEFAULT_CONFIG = {
# continues, Shift+Enter reported distinctly. False restores the c-j submit fallback for
# POSIX PTYs whose plain Enter arrives as LF.
"cli_multiline_shortcuts": True,
# Interface bare `hermes`/`hermes chat` launches: "cli" (prompt_toolkit REPL) | "tui" (Ink).
# Flags win: `--cli` forces the REPL, `--tui` / HERMES_TUI=1 forces the TUI.
"interface": "cli",
# `hermes`/`hermes chat` launches "cli" (prompt_toolkit REPL) | "tui" (Ink); --cli, --tui, HERMES_TUI=1 win.
# cli_layout = the REPL's look: "modern" (glyph rows, status under the composer) | "classic" (boxed panels).
"interface": "cli", "cli_layout": "modern",
# Native TUI uses the terminal's primary buffer and scrollback instead of the custom
# alternate-screen viewport. Flags win: `--native` / `--tui-native` and `--cli`.
"tui_native": False,
+22 -6
View File
@@ -662,10 +662,14 @@ class GatewayAgent:
def _take_turn_route(self):
"""The ``/model --once`` pick for the admission being built, consumed here: one admission carries
it, even when a later ``run_conversation`` submits while this one's ``prompt.submit`` still blocks.
A refusal that admitted nothing (``session_busy``) puts it back with ``_give_back_turn_route``."""
A refusal that admitted nothing (``session_busy``) puts it back with ``_give_back_turn_route``.
Staging already compared the pick with the session's route (``_route_off_session``), so it is not
compared again: its ``model`` may be an alias spelled like the session's model, and the owner runs a
plain pick of the session's own route on the session's endpoint and key (``one_turn_runtime``)."""
pending, reasoning = self._turn_route, self._turn_reasoning
self._turn_route = self._turn_reasoning = self._taken_turn_route = None
if not pending or not self._route_off_session(**pending):
if not pending:
return None
self._taken_turn_route = (pending, reasoning)
return {**pending, **({"reasoning": reasoning} if reasoning else {})}
@@ -708,8 +712,10 @@ class GatewayAgent:
adopted = take_admitted_turn(self, persist_user_message if isinstance(persist_user_message, str)
else user_message)
if adopted is not None:
# A notification turn the gateway already admitted and is running (its frames are queued).
# A turn the gateway already admitted and is running (its frames are queued). A recovered
# ``/queue`` turn missed some frames: its reply is completed from the receipt.
receipt = adopted
turn.gapped = bool(adopted.get("recovered"))
else:
self._drain_stale()
self._taken_turn_route = None
@@ -745,7 +751,8 @@ class GatewayAgent:
self._generation = receipt["execution_generation"]
starts: dict = {}
try:
outcome = self._pump(stream_callback, starts, turn)
settled = (adopted or {}).get("receipt")
outcome = {"_receipt": settled} if settled else self._pump(stream_callback, starts, turn)
finally:
if self._owns(turn):
self._generation_done()
@@ -760,7 +767,9 @@ class GatewayAgent:
return result
def _drain_stale(self):
"""Frames left from an earlier turn of this viewer (a late settlement) are not this turn's."""
"""Frames left from an earlier turn of this viewer (a late settlement) are not this turn's. A
``/queue`` admission's start among them is remembered: that turn is painted after this one."""
from hermes_cli.gateway_tools_notify import missed_start
while True:
try:
frame = self.connection.events.get_nowait()
@@ -771,6 +780,9 @@ class GatewayAgent:
continue # a connection a reconnect already replaced
self.connection.events.put(frame)
return
params = frame.get("params") if isinstance(frame, dict) else None
if isinstance(params, dict) and params.get("session_id") == self.session_id:
missed_start(self._state(), params)
def _generation_done(self):
self._generation = None
@@ -818,6 +830,8 @@ class GatewayAgent:
kind, payload = params.get("type"), params.get("payload") or {}
admission = params.get("admission_id") or (payload.get("admission_id") if isinstance(payload, dict) else None)
if self._admission and admission and admission != self._admission:
from hermes_cli.gateway_tools_notify import missed_start
missed_start(self._state(), params) # a /queue turn run ahead of this one: painted after it
continue
if params.get("execution_generation") is not None:
self._generation = params["execution_generation"]
@@ -942,8 +956,10 @@ class GatewayAgent:
diff = payload.get("inline_diff")
if isinstance(diff, str) and diff:
from cli import _cprint
from hermes_cli.cli_modern_layout import diff_printer
emit = diff_printer(_cprint)
for line in diff.rstrip("\n").splitlines():
_cprint(line)
emit(line)
if name == "delegate_task" and self.tool_complete_callback is not None:
self.tool_complete_callback(call_id, name, args, result)
elif self.tool_complete_callback is not None:
+3 -4
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import sys
import uuid
from hermes_cli.gateway_client import GatewayClientError, connect_gateway
from hermes_cli.gateway_client import RESUME_KEY_MISMATCH, GatewayClientError, connect_gateway
# These options change execution or require frontend facilities not yet exposed by
# the authority. Reject them, rather than mutate process-wide gateway settings.
@@ -133,8 +133,7 @@ async def _resume(client, args, **params):
return await client.rpc("session.resume", **params, **({"api_key": key} if key else {}))
except GatewayClientError as exc:
if key and str(exc) == "admission_conflict":
raise GatewayClientError("--api-key on resume must be the key this session was launched with "
"(a session launched without --api-key keeps its profile credentials).") from None
raise GatewayClientError(RESUME_KEY_MISMATCH) from None
raise
@@ -267,7 +266,7 @@ def resume_policy_error(keys):
def exit_on_resume_refusal(exc):
"""A refused resume is a usage error on every classic path (interactive startup's copy): ``Error:``, exit 2."""
if isinstance(exc, GatewayClientError) and str(exc).startswith(_RESUME_POLICY_MISMATCH):
if isinstance(exc, GatewayClientError) and str(exc).startswith((_RESUME_POLICY_MISMATCH, RESUME_KEY_MISMATCH)):
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(2) from None
+3
View File
@@ -16,6 +16,9 @@ GATEWAY_WS_TICKET_PREFIX = "hermes-gateway-ticket."
# Per-request answer budget. Owner verbs are admissions/reads that answer promptly; the one that
# legitimately runs long passes its own budget (``rpc(..., _timeout=...)``).
DEFAULT_RPC_TIMEOUT = 30
# A typed ``--api-key`` on a resume that the owner does not bind (``rebind_launch_key``): every CLI says this.
RESUME_KEY_MISMATCH = ("--api-key on resume must be the key this session was launched with "
"(a session launched without --api-key keeps its profile credentials).")
# Canonical ``/compress`` answers only after the summary call(s) and the commit: a long session's
# summary routinely takes more than 30 s (``auxiliary.compression.timeout`` defaults to 120 s per
# request, ``compression.context_total_ceiling_seconds`` to 600 s). Same budget as Desktop's
+72 -2
View File
@@ -16,7 +16,7 @@ import queue
import threading
import time
from hermes_cli.gateway_client import DEFAULT_RPC_TIMEOUT, GatewayClientError
from hermes_cli.gateway_client import DEFAULT_RPC_TIMEOUT, RESUME_KEY_MISMATCH, GatewayClientError, GatewayRPCError
logger = logging.getLogger(__name__)
@@ -26,12 +26,29 @@ DISCONNECTED = ("Gateway disconnected; turn outcome is unknown. Resume the print
NOT_SENT = "Gateway disconnected before the request was sent; nothing was sent."
# Resume refusals meaning the session itself is gone for this viewer (gateway/session_controls).
_GONE = frozenset({"not_found", "permission_denied", "profile_mismatch"})
# Keyed-resume refusals a plain resume answers as main did: the key does not apply to this session
# (``rebind_launch_key``: keyless or non-local, another key held, another fingerprint), or the viewer
# lacks the ``session:control`` a rebind needs but may still read. Any other refusal is the resume's.
_KEY_NOT_TAKEN = frozenset({"admission_conflict", "permission_denied"})
class CallStopped(GatewayClientError):
"""The caller stopped waiting (:meth:`GatewayConnection.call_until`); the request was sent."""
def _resume_plain(exc, session_id, typed):
"""A keyed ``session.resume`` the owner refused with ``exc``: returns when the caller resumes plain,
else raises. Only a refusal in ``_KEY_NOT_TAKEN`` resumes plain (a keyed startup alias on a Desktop/TUI
session); any other is raised, not hidden by a keyless attach whose first turn fails. ``typed`` (a typed
``--api-key`` the user resumes with) never resumes plain: ``admission_conflict`` is the thin client's
refusal."""
if typed and str(exc) == "admission_conflict":
raise GatewayClientError(RESUME_KEY_MISMATCH) from None
if typed or str(exc) not in _KEY_NOT_TAKEN:
raise exc
logger.debug("session.resume %s: launch key not taken (%s); resuming without it", session_id, exc)
def lost_before(frame, epoch) -> bool:
"""``frame`` is a transport error from a connection older than ``epoch``: a reconnect replaced it,
so it is no outcome of anything submitted on ``epoch``."""
@@ -61,6 +78,24 @@ class SessionState:
self.request_listeners: list = []
# A gateway-admitted notification turn the idle CLI queued a header for (gateway_tools_notify).
self.admitted_turn: dict | None = None
# The owner's pending admissions as last published (snapshot or ``session.info``), and the
# ``/queue`` inputs this viewer admitted there (``admission_id -> text``), so the turn the owner
# runs for one is painted like a typed turn (``gateway_tools_notify.queue_admitted_turn``).
self.pending: list = []
self.queued_inputs: dict = {}
# Started ``/queue`` admissions waiting for their turn (``admission_id -> receipt-like``), and the
# one whose item the input loop just took (``gateway_tools_notify.arm_adopted``).
self.adopted: dict = {}
self.armed_admission: str | None = None
# Each pending list the owner published (or a snapshot carried) bumps ``pending_seq``; a
# ``/queue`` admission whose ``message.start`` this viewer dropped unpainted (a local turn's
# drain or pump) is in ``missed``. Both make the idle tick read its receipt (``recover_queued``).
self.pending_seq = 0
# ``admission_id -> pending_seq`` when this viewer queued it: the dock shows it only until the
# owner publishes a list after that (``gateway_queue.published_queue``).
self.queued_at: dict = {}
self.recovered_seq = 0
self.missed: set = set()
# ``session.replay_gap``s seen, and how many a re-attach covered: the owner retired this viewer's
# subscription on overflow, so its frames stop until ``GatewayConnection.reattach``.
self.gaps = self.attached_gaps = 0
@@ -71,6 +106,9 @@ class SessionState:
self.subscription_id = snapshot.get("subscription_id", self.subscription_id)
self.execution_generation = snapshot.get("execution_generation", self.execution_generation)
self.messages = list(snapshot.get("messages") or [])
if isinstance(snapshot.get("pending"), list):
self.pending = list(snapshot["pending"])
self.pending_seq += 1 # a (re)attach replays no frames: the idle tick checks this viewer's /queue rows
if self.info.get("title"):
self.title = self.info["title"]
@@ -83,6 +121,9 @@ class SessionState:
def apply_event(self, kind, payload):
if kind in ("session.info", "session.updated") and isinstance(payload, dict):
self.info.update({k: v for k, v in payload.items() if k != "session_id"})
if isinstance(payload.get("pending"), list):
self.pending = list(payload["pending"])
self.pending_seq += 1
if payload.get("title"):
self.title = payload["title"]
elif kind == "todo.updated" and isinstance(payload, dict):
@@ -137,6 +178,12 @@ class GatewayConnection:
self._side_lock = threading.Lock()
# The wake events of :meth:`call_until` commands in flight, each on its own socket.
self._commands: set[threading.Event] = set()
# This CLI's launch-only key (``--api-key`` or a keyed startup alias), set by the session store:
# an owner restart revokes it, so every resume re-supplies it (the owner's ``rebind_launch_key``).
self.launch_api_key = None
# True when that key is the typed ``--api-key`` (not a keyed startup alias's): a startup resume the
# owner will not bind it to is refused as the thin client refuses it, never attached plain.
self.launch_api_key_typed = False
@classmethod
def shared(cls):
@@ -248,13 +295,36 @@ class GatewayConnection:
if state.subscription_id is None:
continue
try:
state.adopt_snapshot(await client.rpc("session.resume", session_id=session_id))
state.adopt_snapshot(await self._resume(client, session_id))
except GatewayClientError as exc:
if str(exc) not in _GONE:
raise
logger.debug("session.resume %s after reconnect: %s", session_id, exc)
state.subscription_id = None
def resume(self, call, session_id, *, typed=False):
"""``session.resume`` through ``call`` (the store's attach), re-supplying :attr:`launch_api_key`;
a refusal of the keyed resume is :func:`_resume_plain`'s to decide. ``typed``: the user resumes with
a typed ``--api-key``."""
key = self.launch_api_key
if key:
try:
return call("session.resume", session_id=session_id, api_key=key)
except GatewayRPCError as exc:
_resume_plain(exc, session_id, typed)
return call("session.resume", session_id=session_id)
async def _resume(self, client, session_id):
""":meth:`resume` for :meth:`_resubscribe`, awaited on this loop so a cancelled reconnect cancels
its request. A reconnect types nothing: never ``typed``."""
key = self.launch_api_key
if key:
try:
return await client.rpc("session.resume", session_id=session_id, api_key=key)
except GatewayRPCError as exc:
_resume_plain(exc, session_id, typed=False)
return await client.rpc("session.resume", session_id=session_id)
async def _pump(self, client):
while True:
frame = await client.events.get()
+235
View File
@@ -0,0 +1,235 @@
"""The classic CLI's ``/queue`` over the gateway's durable FIFO (refs #134889, N5).
On main a prompt queued while a turn ran sat in the CLI's ``_pending_input``: no
``session_admissions`` row, so an owner restart or a disconnect lost it. Over the session authority
``/queue <prompt>`` typed while a turn runs is an ordinary queued admission (``prompt.submit
{queued: true}``, the same path Ink and Desktop queue busy input on): the owner commits it at accept,
keeps it across a restart and runs it once, in FIFO order, after the current turn.
The CLI keeps no copy to resend. It remembers only which admissions it queued
(``SessionState.queued_inputs``) so the turn the owner runs for one is painted like a typed turn
(``gateway_tools_notify.queue_admitted_turn`` adopts it), and ``/queue list|edit|rm|clear`` read and
change the owner's queue (``prompt.receipt`` / ``prompt.replace_queued`` / ``prompt.cancel``).
"""
from __future__ import annotations
import logging
import uuid
from hermes_cli.gateway_client import GatewayClientError
logger = logging.getLogger(__name__)
def _state(cli, store):
return store.connection.state(cli.session_id)
class QueueOutcomeUnknown(GatewayClientError):
"""The submit was sent but no answer came back (timeout, disconnect): the owner may hold it.
No admission id came back, so nothing enters ``queued_inputs``. If the owner did queue it, the row
shows in ``/queue list`` (from the owner's published list) and runs once, but this CLI never adopts
its turn: the reply lands in the session's history, not painted live here. The user is told to
check ``/queue list`` instead of re-sending. This is rare (two lost answers in a row).
"""
def submit_queued(cli, store, text: str) -> str:
"""Admit ``text`` behind the running turn in the owner's FIFO; returns its admission id.
A refusal (``GatewayRPCError``) or a request never sent raises as is: nothing was queued. A lost
answer is asked once more under the SAME ``submission_id``, which the owner answers with the
admission it already holds (``admit_session_input`` keys it by principal, session and request id),
so the prompt is queued exactly once. Still no answer: :class:`QueueOutcomeUnknown`.
"""
from hermes_cli.gateway_client import GatewayRPCError
from hermes_cli.gateway_connection import NOT_SENT
submission = uuid.uuid4().hex
for attempt in (1, 2):
try:
receipt = store.call("prompt.submit", session_id=cli.session_id, submission_id=submission,
text=text, queued=True)
break
except GatewayRPCError:
raise
except GatewayClientError as exc:
if str(exc) == NOT_SENT and attempt == 1:
raise # never reached a socket: nothing is in doubt
if attempt == 2:
raise QueueOutcomeUnknown(str(exc)) from exc
logger.debug("prompt.submit answer lost (%s); asking again under the same identity", exc)
admission = (receipt or {}).get("admission_id")
if not admission:
raise GatewayClientError("the gateway did not admit the queued prompt")
state = _state(cli, store)
state.queued_inputs[admission] = text
state.queued_at[admission] = state.pending_seq
return admission
def queued_rows(cli, store) -> list[tuple[str, str]]:
"""``[(admission_id, text)]`` still queued in the owner's FIFO, in run order.
This viewer's own admissions are read back with ``prompt.receipt`` (one it finds started or settled
is left for the idle tick to paint); other viewers' queued rows come from the last pending list the owner published.
"""
state = _state(cli, store)
rows: dict[str, tuple[int, str]] = {}
for row in state.pending or ():
if isinstance(row, dict) and row.get("status") == "queued" and row.get("admission_id"):
rows[row["admission_id"]] = (int(row.get("sequence") or 0), str(row.get("text") or ""))
for admission, text in list(state.queued_inputs.items()):
try:
receipt = store.call("prompt.receipt", session_id=cli.session_id, admission_id=admission)
except GatewayClientError as exc:
logger.debug("prompt.receipt %s failed: %s", admission, exc)
rows.setdefault(admission, (1 << 62, text)) # unread: still shown, never dropped
continue
if receipt.get("status") == "queued":
rows[admission] = (int(receipt.get("sequence") or 0), text)
else:
# Started or settled: no longer queued, but its turn may not be painted yet. The idle tick
# paints it (``gateway_tools_notify.recover_queued``); listing never erases that.
rows.pop(admission, None)
state.missed.add(admission)
return [(admission, text) for admission, (_, text) in sorted(rows.items(), key=lambda kv: kv[1][0])]
def published_queue(cli, store) -> list[str]:
"""The texts the owner last published as queued, plus this viewer's admissions submitted after that
list (no RPC: the dock repaints on every tick).
The owner's published list is authoritative: an admission it no longer lists (cancelled, replaced or
steered by another client, or started) is not shown as queued, even while ``queued_inputs`` still
owes its turn a paint."""
state = _state(cli, store)
for gone in [admission for admission in state.queued_at if admission not in state.queued_inputs]:
state.queued_at.pop(gone, None)
rows = [row for row in state.pending or () if isinstance(row, dict)]
known = {row.get("admission_id") for row in rows}
fresh = [text for admission, text in state.queued_inputs.items()
if admission not in known and state.queued_at.get(admission, -1) >= state.pending_seq]
return [str(row.get("text") or "") for row in rows if row.get("status") == "queued"] + fresh
# How long the input loop holds a local input behind this viewer's own ``/queue`` admission at most.
OWN_QUEUE_HOLD_S = 2.0
def _own_head(state):
"""This viewer's ``/queue`` admission at the head of the owner's queue, not yet adopted; None when
the head is someone else's, nothing is queued, or the FIFO is paused on an ``unknown`` row."""
rows = [row for row in state.pending or () if isinstance(row, dict)]
if any(row.get("status") == "unknown" for row in rows):
return None # paused until the owner resolves that row: never hold typing behind it
queued = sorted((row for row in rows if row.get("status") == "queued" and row.get("admission_id")),
key=lambda row: int(row.get("sequence") or 0))
if queued:
head = queued[0]["admission_id"]
else: # submitted after the last published list (``published_queue``'s fresh rows)
listed = {row.get("admission_id") for row in rows}
head = next((a for a in state.queued_inputs
if a not in listed and state.queued_at.get(a, -1) >= state.pending_seq), None)
return head if head in state.queued_inputs and head not in state.adopted else None
class OwnQueueHold:
"""A local input (a slash command, a typed turn) taken while this viewer's own ``/queue`` admission
is the owner's next turn. On main both sat in ``_pending_input`` in entry order; now the admission
is in the owner's FIFO and its turn is adopted only when the owner's ``message.start`` arrives, so
the input loop holds the local item until that admission was taken (or left the queue).
Bounded: an ``unknown`` row (a paused FIFO) never starts a hold; an owner that publishes an idle
list still queuing it (beyond the one a just-settled turn publishes before its claim) releases it;
and ``OWN_QUEUE_HOLD_S`` caps the wait. The admission itself is never touched.
"""
def __init__(self, item, state, admission):
import time
self.item, self.state, self.admission = item, state, admission
self.seq = state.pending_seq
# A turn the owner reports running settles with one idle list (still queuing this admission)
# before its drain claims it: that list is expected, a further one means the FIFO is paused.
self.allowed_idle_lists = 1 if state.info.get("running") else 0
self.idle_lists = 0
self.deadline = time.monotonic() + OWN_QUEUE_HOLD_S
self.taken = False
@classmethod
def begin(cls, cli, item):
from cli import _SeededQueryMessage
from hermes_cli.gateway_tools_notify import gateway_store
from tools.process_registry_notifications import TimelineNotification
if getattr(cli, "_agent_running", False) or isinstance(item, (_SeededQueryMessage, TimelineNotification)):
return None # a turn the owner already runs (or a seeded prompt) is never held
store = gateway_store(cli) if getattr(cli, "session_id", None) else None
if store is None:
return None
state = _state(cli, store)
admission = _own_head(state)
return cls(item, state, admission) if admission else None
def take_gateway_turn(self, pending):
"""Pop a turn the owner is running now (an adopted admission, a notification header) from the
head of ``_pending_input``: those run before the held item, typed items stay behind it."""
from tools.process_registry_notifications import TimelineNotification
with pending.mutex:
if not pending.queue:
return None
head = pending.queue[0]
if not (getattr(head, "admission_id", None) or isinstance(head, TimelineNotification)):
return None
pending.queue.popleft()
pending.unfinished_tasks = max(0, pending.unfinished_tasks - 1)
if getattr(head, "admission_id", None) == self.admission:
self.taken = True
return head
def done(self) -> bool:
import time
if self.taken or time.monotonic() >= self.deadline:
return True
state = self.state
if _own_head(state) != self.admission:
# Adopted: its item is at the head of ``_pending_input``, taken on the next step. Otherwise
# it started unseen (the idle tick recovers it), was cancelled, or is no longer next.
return self.admission not in state.adopted
if state.pending_seq != self.seq:
self.seq = state.pending_seq
if not state.info.get("running"):
self.idle_lists += 1
return self.idle_lists > self.allowed_idle_lists # idle and still not claiming it: stop waiting
STARTED_REFUSALS = frozenset({"stale_generation", "admission_started"})
def cancel(cli, store, admission: str) -> str:
"""Retire one queued admission: ``"cancelled"`` when it never ran, ``"stopped"`` when the drain had
claimed it and the owner interrupted that run instead (``session_controls.cancel``), ``"started"``
when it already ran. A turn that ran is still owed a paint, so only a cancelled one leaves
``queued_inputs``."""
receipt = store.call("prompt.cancel", session_id=cli.session_id, admission_id=admission) or {}
if receipt.get("status") == "terminal" and receipt.get("outcome") == "cancelled":
state = _state(cli, store)
state.queued_inputs.pop(admission, None)
state.missed.discard(admission)
return "cancelled"
_state(cli, store).missed.add(admission) # the idle tick paints whatever ran
if receipt.get("status") == "started" or receipt.get("outcome") == "interrupted":
return "stopped"
return "started" # it already ran to its end: nothing was stopped
def replace(cli, store, admission: str, text: str) -> str | None:
"""``prompt.replace_queued``: the edited prompt is a new admission at the back of the FIFO."""
receipt = store.call("prompt.replace_queued", session_id=cli.session_id, admission_id=admission,
submission_id=uuid.uuid4().hex, text=text)
state = _state(cli, store)
state.queued_inputs.pop(admission, None)
new = (receipt or {}).get("admission_id")
if new:
state.queued_inputs[new] = text
state.queued_at[new] = state.pending_seq
return new
+10 -4
View File
@@ -121,20 +121,26 @@ class GatewaySessionStore:
self.connection.state(sid).adopt_snapshot(snapshot)
return sid
def attach(self, session_id):
"""``session.resume`` once per session id this viewer uses (idempotent)."""
def attach(self, session_id, *, typed=False):
"""``session.resume`` once per session id this viewer uses (idempotent), carrying this CLI's launch
key as the thin client does (``GatewayConnection.resume``, the reconnect's too). A session that does
not take the key (a keyed startup alias resuming a Desktop/TUI session) attaches as is. ``typed``
(only :meth:`open_session`'s startup resume, where the user typed ``--api-key`` for that session)
refuses it instead, as the thin client does."""
if session_id in self.snapshots:
return self.snapshots[session_id]
snapshot = self.call("session.resume", session_id=session_id)
snapshot = self.connection.resume(self.call, session_id, typed=typed)
self._adopt(snapshot)
return snapshot
def open_session(self, cli, launch=None):
"""Create (or resume) the gateway session for this CLI and adopt its id. A resume target the
gateway does not know is left as is: main's own "Session not found" path reports it."""
key = self.connection.launch_api_key = (launch or {}).get("api_key") or None
self.connection.launch_api_key_typed = key is not None and key == getattr(cli, "_typed_api_key", None)
if getattr(cli, "_resumed", False):
try:
snapshot = self.attach(cli.session_id)
snapshot = self.attach(cli.session_id, typed=self.connection.launch_api_key_typed)
except GatewayClientError as exc:
if str(exc) in {"not_found", "permission_denied"}:
return None
+10 -1
View File
@@ -19,8 +19,16 @@ def _mirror_busy(cli, arg):
cli.busy_input_mode = arg.lower()
def _watch_reviewer(cli, _arg):
"""/review spawns its reviewer off-turn: have the live-work dock poll for it right away."""
from hermes_cli.gateway_tools import process_view
view = process_view()
if view is not None:
view.watch_subagents()
GATEWAY_SETTINGS = {"personality": None, "busy": _mirror_busy, "approvals": None, "footer": None, "fast": None,
"refine": None, "review": None}
"refine": None, "review": _watch_reviewer}
def run(cli, canonical, cmd):
@@ -47,3 +55,4 @@ def run(cli, canonical, cmd):
if mirror is not None:
mirror(cli, arg)
return True
+107
View File
@@ -51,6 +51,12 @@ class GatewayProcessView:
self.delegations = 0
self._polled = 0.0
self._lock = threading.Lock()
self.subagents: list = []
self._subagents_polled = 0.0
self._subagents_seen = 0.0
self._subagents_lock = threading.Lock()
self._tail = None
self._subagents_watch_until = 0.0
@property
def session_id(self):
@@ -128,6 +134,107 @@ class GatewayProcessView:
value = usage.get("active_subagents")
return value if isinstance(value, int) else 0
# -- the subagent registry main's Ctrl+T monitor and dock read ---------------------------
def watch_subagents(self, seconds=_RETAIN_SECONDS):
"""Poll ``subagent.list`` for a while even with no turn running: an off-turn spawn
(``/review``) moves neither ``busy`` nor the usage count the gate below reads."""
self._subagents_watch_until = time.time() + seconds
self._subagents_polled = 0.0
def subagent_rows(self, *, busy):
"""The session's live children (``subagent.list``). Asks at most once a second, and only
while a turn runs (``busy``), children were listed last time, the gateway counts some, or
an off-turn spawn asked to be watched."""
now = time.time()
wanted = (busy or self.subagents or now < self._subagents_watch_until
or self.active_delegations())
if wanted and now - self._subagents_polled >= _POLL_SECONDS and self._subagents_lock.acquire(blocking=False):
try:
self._subagents_polled = now
result = self.connection.call("subagent.list", session_id=self.session_id, timeout=5)
self.subagents = list((result or {}).get("subagents") or [])
self._subagents_seen = now
except (GatewayClientError, RuntimeError) as exc:
logger.debug("subagent.list failed: %s", exc)
if now - self._subagents_seen > _RETAIN_SECONDS:
self.subagents = [] # no answer for a minute: drop the rows and stop polling
finally:
self._subagents_lock.release()
return [dict(row) for row in self.subagents]
def subagent_tail(self, subagent_id, *, fetch=False):
"""The child's live transcript tail (``subagent.tail``); None when the gateway has none.
The detail view repaints every frame and reads only the cache; ``fetch`` (the monitor's
tick, on the spinner thread) asks the gateway at most once a second, failures included."""
now = time.time()
cached = self._tail if self._tail and self._tail[0] == subagent_id else None
if not fetch or (cached and now - cached[1] < _POLL_SECONDS):
return cached[2] if cached else None
try:
result = self.connection.call("subagent.tail", session_id=self.session_id,
subagent_id=subagent_id, timeout=5) or {}
text = result.get("text") if result.get("available") else None
if text is not None: # filter once here, not on every repaint of the detail view
text = "".join(c for c in text if c.isprintable() or c in "\n\t")
except (GatewayClientError, RuntimeError) as exc:
logger.debug("subagent.tail failed: %s", exc)
text = None
self._tail = (subagent_id, now, text)
return text
def subagent_control(self, action, subagent_id, message=None):
"""Steer (``subagent.steer``) or stop (``subagent.interrupt``) one child of this session, in
the shape ``delegate_tool_registry._handle_control_action`` answers: ``note`` or ``error``."""
from tools.delegate_tool_registry import _CONTROL_OUTCOMES
status, note, failure = _CONTROL_OUTCOMES[action]
try:
if action == "steer":
result = self.connection.call("subagent.steer", session_id=self.session_id,
subagent_id=subagent_id, text=message, timeout=5) or {}
ok = result.get("status") == "queued"
else:
result = self.connection.call("subagent.interrupt", session_id=self.session_id,
subagent_id=subagent_id, timeout=5) or {}
ok = bool(result.get("found"))
except GatewayClientError as exc:
return {"error": str(exc)}
self._subagents_polled = 0.0 # the next tick re-reads the roster
if ok:
return {"action": action, "subagent_id": subagent_id, "status": status, "note": note}
return {"error": failure.format(sid=subagent_id)}
class LocalSubagents:
"""This process's own subagent registry behind the view's ``subagent_*`` surface, for a CLI that
runs its agent in-process: the dock and monitor talk to one backend shape either way."""
def __init__(self, cli):
self.cli = cli
self._paths: dict = {}
def subagent_rows(self, *, busy):
from tools.delegate_tool_registry import _list_payload, list_active_subagents
parent = getattr(self.cli, "agent", None)
entries = _list_payload(parent)["subagents"] if parent is not None else []
# The scoped control-plane snapshot supplies authority and transcript paths; the public
# lifecycle record supplies start time and the latest observed tool (as subagent.list does).
live = {r["subagent_id"]: r for r in list_active_subagents()} if entries else {}
for row in entries:
record = live.get(row["subagent_id"], {})
row["started_at"], row["last_tool"] = record.get("started_at"), record.get("last_tool") or ""
self._paths = {row["subagent_id"]: row.get("live_transcript") for row in entries}
return entries
def subagent_tail(self, subagent_id, *, fetch=False):
from hermes_cli.cli_subagent_monitor import read_tail
return read_tail(self._paths.get(subagent_id))
def subagent_control(self, action, subagent_id, message=None):
import json
from tools.delegate_tool_registry import _handle_control_action
return json.loads(_handle_control_action(action, subagent_id, message, getattr(self.cli, "agent", None)))
class _OpenedSession:
"""The CLI's open gateway session before any chat agent exists (``_init_agent`` builds that on the
+107 -1
View File
@@ -41,12 +41,15 @@ def queue_admitted_turn(cli, store) -> None:
from tools.process_registry_notifications import TimelineNotification
connection, session_id = store.connection, getattr(cli, "session_id", None)
state = connection.state(session_id)
if _adoption_waiting(cli, state):
return # an adopted /queue turn is waiting for the input loop: its frames stay queued for its pump
state.admitted_turn = None # a header queued earlier whose turn never ran is stale now
events = connection.events
while True:
try:
frame = events.get_nowait()
except queue.Empty:
recover_queued(cli, store)
return
if isinstance(frame, Exception):
from hermes_cli.gateway_connection import lost_before
@@ -66,6 +69,16 @@ def queue_admitted_turn(cli, store) -> None:
continue
if params.get("type") != "message.start" or not params.get("admission_id"):
continue
queued_text = state.queued_inputs.get(params["admission_id"])
if queued_text is not None:
# A ``/queue`` prompt this viewer admitted (#134889 N5): the owner runs it now, and main's
# input path paints and pumps it as a typed turn. Literal: it is a prompt, never a command.
# Adopted by admission id (the item carries it), never by text: the input path may rewrite
# the text, and a second ``prompt.submit`` would run the prompt twice.
from cli import _SeededQueryMessage
_adopt(cli, state, {"admission_id": params["admission_id"],
"execution_generation": params.get("execution_generation")}, queued_text)
return
card = _card(params)
if card is None:
continue
@@ -77,6 +90,84 @@ def queue_admitted_turn(cli, store) -> None:
return
def _adopt(cli, state, adopted, text):
from cli import _SeededQueryMessage
state.missed.discard(adopted["admission_id"])
state.adopted[adopted["admission_id"]] = adopted
_put_first(cli._pending_input, _SeededQueryMessage(text, admission_id=adopted["admission_id"]))
def _adoption_waiting(cli, state) -> bool:
"""An adopted item is still in ``_pending_input`` (one ``/queue clear`` dropped is forgotten)."""
if not state.adopted:
return False
pending = getattr(cli, "_pending_input", None)
if pending is None:
return False
with pending.mutex:
waiting = {getattr(item, "admission_id", None) for item in pending.queue}
waiting.add(state.armed_admission) # taken by the input loop, its run_conversation not started yet
for admission in [a for a in state.adopted if a not in waiting]:
state.adopted.pop(admission, None)
return bool(state.adopted)
_NEVER_RAN = {"cancelled", "rejected"}
def recover_queued(cli, store) -> None:
"""Idle tick, after the stream had no ``message.start`` for them: this viewer's ``/queue``
admissions the owner started or settled without this viewer painting the turn (a local turn's
drain or pump dropped the start, or it ran across a reconnect, which replays no frames). Read on
a new published pending list or a dropped start only, never every tick.
Still running: adopted and pumped live, its reply completed from the receipt (``recovered``).
Settled: adopted with its saved result, painted without a pump. One that never ran (cancelled,
rejected) is forgotten: the only point where ``queued_inputs`` drops a row unpainted.
"""
from hermes_cli.gateway_client import GatewayClientError
state = store.connection.state(getattr(cli, "session_id", None))
if not state.queued_inputs or (state.recovered_seq == state.pending_seq and not state.missed):
return
state.recovered_seq = state.pending_seq
listed = {row.get("admission_id"): row.get("status") for row in state.pending or () if isinstance(row, dict)}
for admission, text in list(state.queued_inputs.items()):
if admission in state.adopted or (admission not in state.missed and listed.get(admission) == "queued"):
continue
try:
receipt = store.call("prompt.receipt", session_id=cli.session_id, admission_id=admission,
include_result=True) or {}
except GatewayClientError:
state.recovered_seq = -1 # unread: try again on the next tick
continue
status = receipt.get("status")
if status == "queued":
state.missed.discard(admission)
continue
if status == "terminal" and receipt.get("outcome") in _NEVER_RAN:
state.queued_inputs.pop(admission, None)
state.missed.discard(admission)
continue
if status not in ("started", "terminal"):
# unknown: the owner resolves it (``/discard`` or recovery settles it terminal, and the next
# read forgets or paints it). Until then it stays here and is re-read once per new pending
# list, never every tick; the row is rare and the read is one receipt.
continue
adopted = {"admission_id": admission, "execution_generation": receipt.get("execution_generation"),
"recovered": True}
if status == "terminal":
adopted["receipt"] = receipt
_adopt(cli, state, adopted, text)
return # one turn at a time: the next one is recovered after this one ran
def missed_start(state, params) -> None:
"""A frame dropped outside its turn: a ``/queue`` admission's ``message.start`` is remembered so
the idle tick paints that turn from its receipt instead of losing it."""
if params.get("type") == "message.start" and params.get("admission_id") in state.queued_inputs:
state.missed.add(params["admission_id"])
def _put_first(pending, item):
"""Ahead of anything already queued: the gateway is running this turn NOW (typed inputs the
post-turn path re-queued are admitted behind it), and a typed turn run first would drop it."""
@@ -86,10 +177,25 @@ def _put_first(pending, item):
pending.not_empty.notify()
def arm_adopted(cli, item) -> None:
"""The input loop took ``item``: when it is an adopted ``/queue`` admission, the next
``run_conversation`` pumps exactly that admission (``take_admitted_turn``)."""
admission = getattr(item, "admission_id", None)
store = gateway_store(cli) if admission else None
if store is not None:
store.connection.state(getattr(cli, "session_id", None)).armed_admission = admission
def take_admitted_turn(agent, text):
"""The admitted turn ``run_conversation(text)`` replays instead of submitting; None (and the
stale adoption dropped) for any other turn."""
stale notification adoption dropped) for any other turn. A ``/queue`` admission is matched by the
id ``arm_adopted`` recorded, a notification turn by its model text."""
state = agent._state()
armed, state.armed_admission = getattr(state, "armed_admission", None), None
adopted = state.adopted.pop(armed, None) if armed else None
if adopted is not None:
state.queued_inputs.pop(armed, None) # painted now: the admission's turn is this one
return adopted
adopted = getattr(state, "admitted_turn", None)
state.admitted_turn = None
if not adopted or adopted.get("text") != text:
+2 -2
View File
@@ -1668,7 +1668,7 @@ def _first_run_setup_guard(args) -> None:
sys.exit(1)
# args attr -> (kwarg, default) passed through to _launch_tui / cli.main.
# args attr -> (kwarg, default) passed through to _launch_tui / cli.main; only cli.main takes --api-key/--base-url.
_CHAT_PASSTHROUGH = (
("provider", None), ("toolsets", None), ("skills", None), ("verbose", None),
("quiet", False), ("query", None), ("image", None), ("resume", None),
@@ -1757,7 +1757,7 @@ def cmd_chat(args):
"ignore_rules": getattr(args, "ignore_rules", False) or safe_mode,
"ignore_user_config": getattr(args, "ignore_user_config", False) or safe_mode,
"compact": getattr(args, "compact", False),
**{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH},
**{k: getattr(args, k, d) for k, d in _CHAT_PASSTHROUGH + (("api_key", None), ("base_url", None))},
}
kwargs = {k: v for k, v in kwargs.items() if v is not None}
+7 -1
View File
@@ -462,6 +462,10 @@ class ModelSwitchResult:
runtime_capabilities: Optional[dict[str, bool]] = None
model_info: Optional[ModelInfo] = None
is_global: bool = False
# The resolved direct alias's own ``base_url`` (and the credential decided for it) replaced the
# provider's route. ``resolved_via_alias`` alone is also set by a built-in shortcut, an endpointless
# entry or a process provider's declared id, which name a model like a plain pick.
alias_endpoint_applied: bool = False
@dataclass(frozen=True)
@@ -1186,6 +1190,7 @@ class _Switch:
new_model: str = ""
target_provider: str = ""
resolved_alias: str = ""
alias_endpoint_applied: bool = False
provider_label: str = ""
api_key: str = ""
base_url: str = ""
@@ -1586,6 +1591,7 @@ def _resolve_switch_credentials(st: _Switch) -> Optional[ModelSwitchResult]:
da = DIRECT_ALIASES.get(st.resolved_alias)
if da is not None and da.base_url:
_apply_direct_alias_endpoint(st, da)
st.alias_endpoint_applied = True
# Fills an empty mode (alias cleared it) and overrides a STALE mode carried from previous
# session state when the host mandates one wire protocol (e.g. gpt-5.x on api.openai.com
@@ -1738,7 +1744,7 @@ def _build_switch_result(st: _Switch) -> ModelSwitchResult:
provider_label=st.provider_label, resolved_via_alias=st.resolved_alias, capabilities=capabilities,
runtime_capabilities={
k: v for k, v in runtime_capabilities.items() if isinstance(k, str) and isinstance(v, bool)},
model_info=model_info, is_global=st.is_global)
model_info=model_info, is_global=st.is_global, alias_endpoint_applied=st.alias_endpoint_applied)
def switch_model(
+564
View File
@@ -0,0 +1,564 @@
"""OAuth clients a plugin declares in ``plugin.yaml``: Hermes signs in, stores and refreshes the grant.
A plugin that calls a third-party API with the user's own OAuth client (a Google Cloud client, for example)
declares it under ``oauth:``. ``hermes auth add <plugin>:<id>`` runs the PKCE login of
:mod:`hermes_cli.auth_oauth_pkce_plugin` in a terminal, the grant lives in the active profile's
``auth.json`` (section ``plugin_oauth``) and Hermes refreshes it. The plugin calls
``credentialed_provider_request(<id>, ...)``: Hermes attaches the bearer for the declared hosts only, so
the plugin never holds a token (#139077; the same contract as the Codex lane, #136117).
Each grant remembers a fingerprint of the ``oauth:`` entry it was made for. A later version of the plugin
that changes the token URL, hosts, scopes or client variables gets ``ProviderNotSignedIn`` until the user
signs in again: the grant is used only for what the user consented to. Like capabilities, this is
consent and visibility, not a sandbox.
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from urllib.parse import urlparse
_SECTION = "plugin_oauth"
_EXPIRY_MARGIN_MS = 120_000 # refresh this long before expiry, as the credential pool does
_TOKEN_TIMEOUT = 10.0 # below AUTH_LOCK_TIMEOUT_SECONDS: a peer waiting for the refresh lock outlives the POST
_CLIENT_ERROR_CODES = frozenset({"invalid_client", "unauthorized_client", "invalid_scope"})
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
_ENV_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
# Lowercase DNS name with at least one dot whose last label starts with a letter: no IP literal, port or scheme.
_HOST_RE = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z](?:[a-z0-9-]*[a-z0-9])?$")
@dataclass(frozen=True)
class OAuthClient:
"""One ``oauth:`` entry of a plugin manifest. Holds variable names, never their values."""
id: str
authorize_url: str
token_url: str
scopes: tuple[str, ...]
client_id_env: str
hosts: tuple[str, ...]
client_secret_env: str = ""
authorize_params: tuple[tuple[str, str], ...] = ()
@property
def fingerprint(self) -> str:
"""Hash of every field: a grant is used only with the entry it was made for."""
return hashlib.sha256(json.dumps(asdict(self), sort_keys=True).encode("utf-8")).hexdigest()
# ── Manifest ───────────────────────────────────────────────────────────────────────────────────────
def _parse_url(url: Any):
"""``urlparse`` of *url*; None for a non-string or a URL it cannot read (bad IPv6 literal or port)."""
if not isinstance(url, str):
return None
try:
parsed = urlparse(url)
_ = parsed.port # urlparse checks the port only when it is read
except ValueError:
return None
return parsed
def _url_problems(where: str, url: Any) -> list[str]:
parsed = _parse_url(url)
if (parsed is None or not parsed.hostname or parsed.username is not None or parsed.password is not None
or parsed.fragment):
return [f"{where}: must be an absolute URL without user info or fragment"]
if parsed.scheme != "https": # no loopback exception: the code, refresh token and client secret travel here
return [f"{where}: must use https"]
return []
def _hermes_owned_env(name: str) -> bool:
"""A variable Hermes itself reads (a provider key, a platform secret): never a plugin's client variable,
or a manifest could have Hermes POST that secret to the token URL it names."""
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config_env_routing import is_registered_env_name
return is_registered_env_name(name) or any(name in p.api_key_env_vars for p in PROVIDER_REGISTRY.values())
def _entry_problems(entry: dict, where: str) -> list[str]:
oauth_id = entry.get("id")
problems = [] if isinstance(oauth_id, str) and _ID_RE.match(oauth_id) else [
f"{where}.id: {entry.get('id')!r} must be lowercase letters, digits and dashes"]
for field in ("authorize_url", "token_url"):
problems += _url_problems(f"{where}.{field}", entry.get(field))
scopes = entry.get("scopes")
if not (isinstance(scopes, list) and scopes and all(isinstance(s, str) and s.strip() for s in scopes)):
problems.append(f"{where}.scopes: must be a non-empty list of scope strings")
for field, required in (("client_id_env", True), ("client_secret_env", False)):
name = entry.get(field)
if (required or name) and not (isinstance(name, str) and _ENV_RE.match(name)):
problems.append(f"{where}.{field}: {name!r} must be an UPPER_SNAKE environment variable name")
elif name and _hermes_owned_env(name):
problems.append(f"{where}.{field}: {name} is a variable Hermes uses itself; declare the plugin's own")
hosts = entry.get("hosts")
if not (isinstance(hosts, list) and hosts and all(isinstance(h, str) and _HOST_RE.match(h) for h in hosts)):
problems.append(f"{where}.hosts: must list lowercase DNS host names (no scheme, port, path or IP address)")
params = entry.get("authorize_params") or {}
if not (isinstance(params, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in params.items())):
problems.append(f"{where}.authorize_params: must map strings to strings")
return problems
def _client(entry: dict) -> OAuthClient:
return OAuthClient(
id=entry["id"], authorize_url=entry["authorize_url"], token_url=entry["token_url"],
scopes=tuple(entry["scopes"]), client_id_env=entry["client_id_env"], hosts=tuple(entry["hosts"]),
client_secret_env=entry.get("client_secret_env") or "",
authorize_params=tuple(sorted((entry.get("authorize_params") or {}).items())))
def parse_oauth_clients(manifest: Mapping[str, Any]) -> tuple[list[OAuthClient], list[str]]:
"""``oauth:`` from a manifest dict: the well-formed clients, and one problem string per defect."""
raw = (manifest or {}).get("oauth")
if raw is None or raw == []:
return [], []
if not isinstance(raw, list):
return [], ["oauth: must be a list of client entries"]
clients: list[OAuthClient] = []
problems: list[str] = []
for index, entry in enumerate(raw):
where = f"oauth[{index}]"
entry_problems = _entry_problems(entry, where) if isinstance(entry, dict) else [f"{where}: must be a mapping"]
if not entry_problems and any(c.id == entry["id"] for c in clients):
entry_problems = [f"{where}.id: {entry['id']!r} is declared twice"]
problems += entry_problems
if not entry_problems:
clients.append(_client(entry))
return clients, problems
def _declared_clients(plugin_dir: Path) -> list[OAuthClient]:
"""The plugin's own ``oauth:`` clients; a malformed block fails loud instead of reading as undeclared."""
from pm.plugin_declarations import native_manifest_file, read_native_manifest
manifest_file = native_manifest_file(plugin_dir)
clients, problems = parse_oauth_clients(read_native_manifest(manifest_file) if manifest_file else {})
if problems:
raise ValueError(f"Plugin '{plugin_dir.name}' has an invalid oauth: block in plugin.yaml: " + "; ".join(problems))
return clients
def plugin_key(plugin_dir: Path) -> str:
"""The plugin's key under the plugins root it lives in (``voice``, ``web/exa``); grants are stored by it."""
from hermes_cli.plugin_provider_requests import _plugin_roots
for path in (plugin_dir.absolute(), plugin_dir.resolve()):
for root in _plugin_roots():
if path != root and path.is_relative_to(root):
return path.relative_to(root).as_posix()
return plugin_dir.name
def _idp_hosts(client: OAuthClient) -> str:
return ", ".join(dict.fromkeys(urlparse(url).hostname for url in (client.authorize_url, client.token_url)))
def oauth_notice(manifest: Mapping[str, Any]) -> str:
"""Install/show text for the declared clients; empty when the plugin declares none."""
name = str((manifest or {}).get("name") or "<plugin>")
return "; ".join(
f"Signs in to {c.id} at {_idp_hosts(c)} for {', '.join(c.hosts)} with `hermes auth add {name}:{c.id}` "
"(Hermes stores and refreshes the token; the plugin never receives it)"
for c in parse_oauth_clients(manifest)[0])
def oauth_surface(manifest: Mapping[str, Any]) -> set[str]:
"""One line per client for the re-pin consent delta: a new entry, or a changed authorize host, token host,
hosts or scopes, shows up as added. Any other change only alters the fingerprint, so the user signs in again."""
lines: set[str] = set()
for c in parse_oauth_clients(manifest)[0]:
lines.add(f"{c.id} ({_idp_hosts(c)} -> {', '.join(c.hosts)}; {' '.join(c.scopes)})")
return lines
def _declared_env_names(manifest: Mapping[str, Any]) -> set[str]:
names: set[str] = set()
for field in ("requires_env", "optional_env"):
for entry in manifest.get(field) or []:
names.add(str(entry.get("name") if isinstance(entry, dict) else entry))
return names
def oauth_admission_problems(manifest: Mapping[str, Any], plugin_dir: Path) -> list[str]:
"""Catalog admission on top of the shape: declared variables, no clash with core sign-ins or ``custom:``."""
from hermes_cli.plugin_provider_requests import _PROVIDERS
clients, problems = parse_oauth_clients(manifest)
declared = _declared_env_names(manifest)
for client in clients:
if client.id in _PROVIDERS:
problems.append(f"oauth id {client.id!r} is a Hermes sign-in provider; pick another id")
for name in filter(None, (client.client_id_env, client.client_secret_env)):
if name not in declared:
problems.append(f"oauth {client.id!r}: {name} must be declared under requires_env or optional_env")
if clients and plugin_dir.name == "custom":
problems.append("a plugin named 'custom' cannot declare oauth: `hermes auth add custom:<name>` is a custom provider")
return problems
def _parent(host: str) -> str:
"""Registered domain, roughly: ``vendor.co.uk`` keeps three labels (no public-suffix list in core)."""
labels = host.split(".")
keep = 3 if len(labels) > 2 and len(labels[-1]) == 2 and len(labels[-2]) <= 3 else 2
return ".".join(labels[-keep:])
def oauth_admission_warnings(manifest: Mapping[str, Any], plugin_dir: Path) -> list[str]:
"""Reviewer warnings: a host outside the sign-in provider's domains (does the IdP own it?), and plugin
code that names a declared token URL (Hermes refreshes the grant)."""
clients = parse_oauth_clients(manifest)[0]
warnings = [f"oauth {c.id!r}: host {host} shares no domain with {_idp_hosts(c)}; check the provider owns it"
for c in clients for host in c.hosts
if _parent(host) not in {_parent(urlparse(u).hostname or "") for u in (c.authorize_url, c.token_url)}]
urls = sorted({c.token_url for c in clients})
for path in sorted(plugin_dir.rglob("*.py")):
try:
text = path.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
continue
warnings += [f"oauth: {path.relative_to(plugin_dir).as_posix()} names the token URL {url}; Hermes "
"refreshes the grant, so the plugin should not call it" for url in urls if url in text]
return warnings
# ── Grant store: section ``plugin_oauth`` of the active profile's auth.json ────────────────────────
def _read_grant(key: str) -> Optional[dict]:
from hermes_cli.auth import _load_auth_store
section = _load_auth_store().get(_SECTION)
row = section.get(key) if isinstance(section, dict) else None
return dict(row) if isinstance(row, dict) else None
def forget_plugin_grants(plugin: str) -> None:
"""Drop every grant of plugin key *plugin* from the active profile (``hermes plugins remove``)."""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
with _auth_store_lock():
store = _load_auth_store()
section = store.get(_SECTION)
stale = [k for k in section if k.rsplit(":", 1)[0] == plugin] if isinstance(section, dict) else []
if stale:
for key in stale:
section.pop(key)
_save_auth_store(store)
def _write_grant(key: str, row: Optional[dict]) -> None:
"""Replace one grant (``None`` removes it) under the auth-store lock; other sections stay as they are."""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store, _store_section
with _auth_store_lock():
store = _load_auth_store()
section = _store_section(store, _SECTION)
if row is None:
section.pop(key, None)
else:
section[key] = row
_save_auth_store(store)
# ── Token endpoint ─────────────────────────────────────────────────────────────────────────────────
def _auth_error(key: str, message: str, code: str):
from hermes_cli.auth_constants import AuthError
return AuthError(f"{key}: {message}", provider=key, code=code)
def _env(name: str, code: str, key: str) -> str:
from agent.secret_scope import get_secret
value = str(get_secret(name) or "").strip()
if not value:
raise _auth_error(key, f"{name} is not set. Add it to this profile's .env (the plugin declares it).", code)
return value
def _json_object(response: Any) -> dict:
try:
payload = response.json()
except ValueError:
return {}
return payload if isinstance(payload, dict) else {}
def _token_error(key: str, status: int, payload: Mapping[str, Any]):
"""Keep the OAuth ``error`` code a caller can act on; never echo the response body."""
from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES
error = str(payload.get("error") or "").strip()
if error in _OAUTH_GRANT_DEAD_CODES or error in _CLIENT_ERROR_CODES:
return _auth_error(key, f"the token endpoint refused the request (HTTP {status}, {error}).", error)
return _auth_error(key, f"the token endpoint failed with HTTP {status}.", "oauth_refresh_failed")
def _require_granted_scopes(client: OAuthClient, payload: Mapping[str, Any], key: str) -> None:
granted = payload.get("scope")
if not isinstance(granted, str) or not granted.strip():
return # the IdP did not say which scopes it granted
missing = [scope for scope in client.scopes if scope not in granted.split()]
if missing:
raise _auth_error(key, "not every permission was granted, so nothing was saved. Missing: "
+ ", ".join(missing), "oauth_scope_not_granted")
def _token_request(client: OAuthClient, key: str) -> Callable[[dict[str, str]], Mapping[str, Any]]:
"""``OAuthPKCEConfig.token_request``: the client credentials are read right before each POST, so the
secret never sits in the frozen config, whose repr would show it."""
def post(fields: dict[str, str]) -> Mapping[str, Any]:
from hermes_cli.auth import _default_verify
from hermes_cli.auth_constants import httpx
form = {**fields, "client_id": _env(client.client_id_env, "oauth_client_id_missing", key)}
if client.client_secret_env:
form["client_secret"] = _env(client.client_secret_env, "oauth_client_secret_missing", key)
failure = ""
try:
response = httpx.post(client.token_url, data=form, headers={"Accept": "application/json"},
timeout=_TOKEN_TIMEOUT, verify=_default_verify(), follow_redirects=False)
except httpx.HTTPError as exc:
failure = type(exc).__name__
if failure: # raised outside the handler: httpx's error keeps the request, whose body holds the secrets
raise _auth_error(key, f"the token request failed ({failure}).", "oauth_refresh_failed")
payload = _json_object(response)
if not 200 <= response.status_code < 300: # a redirect is a failure too: it is never followed
raise _token_error(key, response.status_code, payload)
if fields.get("grant_type") == "authorization_code":
_require_granted_scopes(client, payload, key)
return payload
return post
def _pkce_config(client: OAuthClient, key: str, *, timeout: Optional[float] = None):
from hermes_cli.auth_oauth_pkce_plugin import OAuthPKCEConfig
return OAuthPKCEConfig(
client_id=_env(client.client_id_env, "oauth_client_id_missing", key), authorize_url=client.authorize_url,
token_url=client.token_url, scopes=client.scopes, extra_authorize_params=dict(client.authorize_params),
label=key, token_request=_token_request(client, key), timeout_seconds=timeout or 180.0)
# ── Bearer for credentialed_provider_request ───────────────────────────────────────────────────────
def _not_signed_in(message: str):
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn
return ProviderNotSignedIn(message)
def _revoked(client: OAuthClient, key: str, code: str):
return _not_signed_in(f"The {client.id} sign-in for plugin '{key.rsplit(':', 1)[0]}' was revoked ({code}). "
f"Run `hermes auth add {key}` to sign in again.")
def _usable_grant(row: Optional[dict], client: OAuthClient, key: str) -> dict:
plugin = key.rsplit(":", 1)[0]
if row is None:
raise _not_signed_in(f"Not signed in to {client.id} for plugin '{plugin}' in this Hermes profile. "
f"Run `hermes auth add {key}`.")
if row.get("dead"):
raise _revoked(client, key, str(row["dead"]))
if row.get("fingerprint") != client.fingerprint:
raise _not_signed_in(f"Plugin '{plugin}' changed its oauth entry '{client.id}' since you signed in. "
f"Run `hermes auth add {key}` to sign in again.")
return row
def _live_token(row: Mapping[str, Any]) -> str:
token = str(row.get("access_token") or "")
expires = row.get("expires_at_ms")
live = expires is None or int(expires) > int(time.time() * 1000) + _EXPIRY_MARGIN_MS
return token if token and live else ""
def _refresh(client: OAuthClient, key: str, row: dict) -> str:
"""Rotate the grant; the caller holds the auth-store lock, so a peer process waits instead of spending
the same refresh token, and a logout cannot be undone by a refresh in flight."""
from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES
from hermes_cli.auth_constants import AuthError
from hermes_cli.auth_oauth_pkce_plugin import _post_token
refresh_token = str(row.get("refresh_token") or "")
if not refresh_token:
raise _revoked(client, key, "no_refresh_token")
try:
fields = _post_token(key, _pkce_config(client, key),
{"grant_type": "refresh_token", "refresh_token": refresh_token}, code="oauth_refresh_failed")
except AuthError as exc:
if exc.code in _OAUTH_GRANT_DEAD_CODES:
_write_grant(key, {"dead": exc.code, "fingerprint": row.get("fingerprint")}) # keep no dead tokens
raise _revoked(client, key, str(exc.code)) from None
raise
_write_grant(key, {**row, **fields})
return str(fields["access_token"])
def _bearer(plugin_dir: Path, client: OAuthClient) -> str:
"""A live access token for the plugin's grant. Reading takes no lock (writes are atomic); a refresh
re-reads under the lock, because a peer may have rotated the grant meanwhile."""
from hermes_cli.auth import _auth_store_lock
key = f"{plugin_key(plugin_dir)}:{client.id}"
token = _live_token(_usable_grant(_read_grant(key), client, key))
if token:
return token
with _auth_store_lock():
row = _usable_grant(_read_grant(key), client, key)
return _live_token(row) or _refresh(client, key, row)
def _plugin_oauth_spec(plugin_dir: Path, provider: str):
"""``(origins, resolver)`` for *provider* when the calling plugin declares it under ``oauth:``, else None.
Core-only, like the ``_PROVIDERS`` resolvers: the resolver returns the bearer, so plugins go through
``credentialed_provider_request``, which attributes the caller and checks the origin first."""
client = next((c for c in _declared_clients(plugin_dir) if c.id == provider), None)
if client is None:
return None
def resolve(url: str) -> tuple[dict[str, str], str]:
parsed = urlparse(url)
if parsed.username is not None or parsed.password is not None:
raise PermissionError(f"Refusing to send the {client.id} sign-in to a URL with user info")
# The grant is valid for every declared host, so the request URL (origin already checked) is its base.
return {"Authorization": f"Bearer {_bearer(plugin_dir, client)}"}, url
return _origins(client), resolve
def _origins(client: OAuthClient) -> frozenset:
"""Origins the bearer may go to: each declared host, HTTPS on the default port."""
return frozenset(("https", host, 443) for host in client.hosts)
# ── hermes auth add|status|refresh|logout <plugin>:<id> ────────────────────────────────────────────
def _installed_client(plugin: str, oauth_id: str) -> Optional[tuple[Path, OAuthClient]]:
"""Resolve *plugin* the way ``hermes plugins show`` does, then its ``oauth:`` entry *oauth_id*."""
from hermes_cli.plugins_cmd import _find_plugin_entry
entry = _find_plugin_entry(plugin)
if entry is None or not entry[4]:
return None
plugin_dir = Path(str(entry[4]))
client = next((c for c in _declared_clients(plugin_dir) if c.id == oauth_id), None)
return (plugin_dir, client) if client is not None else None
def _unattended() -> bool:
from tools.approval_context import _is_gateway_approval_context, _no_user_can_answer
return _no_user_can_answer() or _is_gateway_approval_context()
def _cli_add(args: Any, client: OAuthClient, key: str) -> bool:
from hermes_cli.auth_constants import AuthError
from hermes_cli.auth_oauth_pkce_plugin import login
if _unattended():
raise SystemExit(f"`hermes auth add {key}` signs you in through a browser; run it in your own terminal, "
"not from a gateway, cron job or agent session.")
secret = " and the client secret" if client.client_secret_env else ""
print(f"Plugin '{key.rsplit(':', 1)[0]}' signs in to {client.id} at {urlparse(client.authorize_url).hostname}. "
f"The sign-in code{secret} go to {urlparse(client.token_url).hostname}; the token is sent only to "
f"{', '.join(client.hosts)}.")
try:
fields = login(key, _pkce_config(client, key, timeout=getattr(args, "timeout", None)),
open_browser=not getattr(args, "no_browser", False))
except AuthError as exc:
raise SystemExit(f"Login failed: {exc}") from None
_write_grant(key, {**fields, "fingerprint": client.fingerprint})
print(f"Signed in to {client.id} for plugin '{key.rsplit(':', 1)[0]}'; the grant is stored in this Hermes profile.")
return True
def _cli_status(args: Any, client: OAuthClient, key: str) -> bool:
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn
row = _read_grant(key)
try:
_usable_grant(row, client, key)
except ProviderNotSignedIn as exc:
state = "logged out" if row is None else "revoked" if row.get("dead") else "sign-in needed"
print(f"{key}: {state}\n {exc}")
return True
expires = row.get("expires_at_ms") if row else None
print(f"{key}: logged in\n hosts: {', '.join(client.hosts)}" + (
f"\n access token valid until {datetime.fromtimestamp(int(expires) / 1000, UTC).isoformat(timespec='seconds')}"
if expires else ""))
return True
def _cli_refresh(args: Any, client: OAuthClient, key: str) -> bool:
from hermes_cli.auth import _auth_store_lock
from hermes_cli.auth_constants import AuthError
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn
try:
with _auth_store_lock():
_refresh(client, key, _usable_grant(_read_grant(key), client, key))
except (AuthError, ProviderNotSignedIn) as exc:
raise SystemExit(f"Refresh failed: {exc}") from None
print(f"{key}: refreshed")
return True
def _cli_logout(args: Any, client: OAuthClient, key: str) -> bool:
_write_grant(key, None)
print(f"Signed out of {client.id} for plugin '{key.rsplit(':', 1)[0]}'; the grant was removed from this profile.")
return True
def _logout_key(plugin: str, oauth_id: str) -> Optional[str]:
"""The stored grant key ``hermes auth logout <plugin>:<id>`` means, without parsing the manifest."""
from hermes_cli.plugins_cmd import _find_plugin_entry
entry = _find_plugin_entry(plugin)
names = [f"{plugin_key(Path(str(entry[4])))}:{oauth_id}"] if entry is not None and entry[4] else []
return next((key for key in (*names, f"{plugin}:{oauth_id}") if _read_grant(key) is not None), None)
_CLI_ACTIONS = {"add": _cli_add, "status": _cli_status, "refresh": _cli_refresh, "logout": _cli_logout}
def handle_auth_action(action: str, args: Any, provider: str) -> Optional[bool]:
"""``hermes auth add|status|refresh|logout <plugin>:<id>``; None when *provider* is not a plugin's
``oauth:`` entry (``custom:<name>`` stays the custom-provider pool)."""
plugin, sep, oauth_id = str(provider or "").partition(":")
handler = _CLI_ACTIONS.get(action)
if handler is None or not (sep and plugin and oauth_id) or plugin == "custom":
return None
if action == "logout" and (key := _logout_key(plugin, oauth_id)) is not None:
_write_grant(key, None) # needs no valid oauth: entry: the plugin may be gone or its manifest broken
print(f"Signed out of {key}; the grant was removed from this profile.")
return True
try:
found = _installed_client(plugin, oauth_id)
except ValueError as exc:
raise SystemExit(str(exc)) from None
if found is None:
return None
plugin_dir, client = found
return handler(args, client, f"{plugin_key(plugin_dir)}:{client.id}")
__all__ = [
"OAuthClient", "forget_plugin_grants", "handle_auth_action", "oauth_admission_problems",
"oauth_admission_warnings", "oauth_notice", "oauth_surface", "parse_oauth_clients", "plugin_key",
]
+60 -18
View File
@@ -72,14 +72,19 @@ _PROVIDERS: dict[str, tuple[frozenset, Callable[[str], tuple[dict[str, str], str
}
def _calling_plugin_dir() -> Optional[Path]:
"""Directory of the plugin whose code is nearest on the calling stack, or None."""
def _plugin_roots() -> set[Path]:
"""Every plugins directory an installed plugin can live under, as given and resolved."""
from hermes_cli.plugins import get_bundled_plugins_dir
from hermes_constants import get_default_hermes_root, get_hermes_home, get_process_hermes_home
bases = (get_hermes_home() / "plugins", get_process_hermes_home() / "plugins",
get_default_hermes_root() / "plugins", get_bundled_plugins_dir())
roots = {r for base in bases for r in (base.absolute(), base.resolve())}
return {r for base in bases for r in (base.absolute(), base.resolve())}
def _calling_plugin_dir() -> Optional[Path]:
"""Directory of the plugin whose code is nearest on the calling stack, or None."""
roots = _plugin_roots()
frame = sys._getframe(2)
while frame is not None:
raw = Path(frame.f_code.co_filename)
@@ -97,6 +102,12 @@ def _calling_plugin_dir() -> Optional[Path]:
return None
def _detached(exc: BaseException) -> BaseException:
"""*exc* without traceback or chain: those frames hold the token, refresh token or client secret."""
exc.__cause__ = exc.__context__ = None
return exc.with_traceback(None)
def declared_auth_providers(manifest: Mapping[str, Any]) -> list[str]:
"""``requires_auth`` from a manifest dict: the providers whose sign-in the plugin uses."""
raw = (manifest or {}).get("requires_auth")
@@ -105,9 +116,12 @@ def declared_auth_providers(manifest: Mapping[str, Any]) -> list[str]:
def requires_auth_notice(manifest: Mapping[str, Any]) -> str:
"""One line for install/show surfaces; empty when the plugin declares no sign-in use."""
from hermes_cli.plugin_oauth import oauth_notice
providers = declared_auth_providers(manifest)
return (f"Uses your sign-in for: {', '.join(providers)} (Hermes sends the requests; "
"the plugin never receives the token)") if providers else ""
sign_in = (f"Uses your sign-in for: {', '.join(providers)} (Hermes sends the requests; "
"the plugin never receives the token)") if providers else ""
return "; ".join(part for part in (sign_in, oauth_notice(manifest)) if part)
def _declared_providers(plugin_dir: Path) -> list[str]:
@@ -123,32 +137,60 @@ def credentialed_provider_request(
) -> ProviderResponse:
"""Send ``method url`` with the user's *provider* sign-in attached by Hermes.
*provider* is a Hermes sign-in (listed under ``requires_auth``) or an ``id`` the calling plugin
declares under ``oauth:`` (:mod:`hermes_cli.plugin_oauth`); the plugin's own entry wins.
Refuses (``PermissionError``) a caller outside an installed plugin, a plugin whose ``plugin.yaml``
does not list *provider* under ``requires_auth``, and any origin outside the provider's
allowlist; raises :class:`ProviderNotSignedIn` when this profile has no sign-in. Redirects are
returned as-is, never followed. The result carries no credential.
"""
spec = _PROVIDERS.get(provider)
if spec is None:
raise ValueError(f"No signed-in requests for provider {provider!r} (supported: {', '.join(_PROVIDERS)})")
origins, resolve = spec
from hermes_cli.plugin_oauth import _plugin_oauth_spec
plugin_dir = _calling_plugin_dir()
if plugin_dir is None:
raise PermissionError("credentialed_provider_request must be called from an installed plugin's code")
if provider not in _declared_providers(plugin_dir):
raise PermissionError(
f"Plugin '{plugin_dir.name}' must declare `requires_auth: [{provider}]` in plugin.yaml")
# The plugin's own oauth: entry first, so a core provider added later under the same id cannot take it over.
spec = _plugin_oauth_spec(plugin_dir, provider)
if spec is None:
spec = _PROVIDERS.get(provider)
if spec is None:
raise ValueError(f"No signed-in requests for provider {provider!r} (supported: {', '.join(_PROVIDERS)}, "
"or an id the plugin declares under oauth: in plugin.yaml)")
if provider not in _declared_providers(plugin_dir):
raise PermissionError(
f"Plugin '{plugin_dir.name}' must declare `requires_auth: [{provider}]` in plugin.yaml")
origins, resolve = spec
if url_origin(url) not in origins:
raise PermissionError(f"Refusing to send the {provider} sign-in to {url_origin(url)[1] or url!r}")
auth_headers, credential_base = resolve(url)
if url_origin(credential_base) not in origins:
# A pooled gateway key belongs to its own host only (#121486).
raise PermissionError(f"This profile's {provider} credential routes to a custom endpoint; refusing")
sent = {k: v for k, v in (headers or {}).items() if str(k).lower() not in {h.lower() for h in auth_headers}}
import httpx
# Every error the plugin receives past this point is rebuilt without traceback, chain or request: those
# reach the frames and httpx objects that hold the token, refresh token or client secret.
refusal: Optional[BaseException] = None
with httpx.Client(timeout=timeout, follow_redirects=False) as client:
response = client.request(method, url, json=json, headers={**sent, **auth_headers})
request = client.build_request(method, url, json=json, headers=dict(headers or {})) # no credential yet
try:
auth_headers, credential_base = resolve(url)
except httpx.HTTPError as exc: # a resolver's own refresh POST failed: its request holds the refresh token
refusal = type(exc)(str(exc))
except (RuntimeError, OSError, ValueError, LookupError, TypeError) as exc:
refusal = _detached(exc)
else:
if url_origin(credential_base) not in origins:
# A pooled gateway key belongs to its own host only (#121486).
refusal = PermissionError(
f"This profile's {provider} credential routes to a custom endpoint; refusing")
else:
for name in auth_headers:
request.headers.pop(name, None)
request.headers.update(auth_headers)
try:
response = client.send(request)
except httpx.HTTPError as exc:
refusal = type(exc)(str(exc))
auth_headers = request = None
if refusal is not None:
raise refusal
return ProviderResponse(response.status_code, dict(response.headers), response.content)
+17
View File
@@ -185,6 +185,20 @@ def _check_requires_env(report: ValidationReport, manifest: dict) -> None:
report.add("requires_env", True, "all entries UPPER_SNAKE")
def _check_oauth(report: ValidationReport, manifest: dict, plugin_dir: Path) -> None:
"""``oauth:`` clients (hermes_cli.plugin_oauth): shape, https and declared variables fail admission; a
host outside the IdP's domains and plugin code that names a token URL are reviewer warnings."""
from hermes_cli.plugin_oauth import oauth_admission_problems, oauth_admission_warnings
if manifest.get("oauth") in (None, []):
report.add("oauth", True, "not declared")
return
problems = oauth_admission_problems(manifest, plugin_dir)
report.add("oauth", not problems, "; ".join(problems) or "clients valid")
for warning in oauth_admission_warnings(manifest, plugin_dir):
report.warn(warning)
# ─── Capability probe (subprocess-isolated) ──────────────────────────────────
# Self-contained harness run in a scratch child process. Imports the plugin
@@ -529,6 +543,7 @@ def validate_plugin_dir(
_check_requires_hermes(report, manifest)
_check_config_spec(report, manifest)
_check_requires_env(report, manifest)
_check_oauth(report, manifest, plugin_dir)
_check_loadable(report, plugin_dir, manifest)
_check_python_dependencies(report, plugin_dir)
recorded = _check_capabilities(report, manifest, plugin_dir, probe)
@@ -646,6 +661,8 @@ def _validate_portable_plugin(report: ValidationReport, plugin_dir: Path) -> Val
bool(name),
"name present" if name else "plugin.json missing required 'name'",
)
from hermes_cli.agent_plugins import manifest_config_schema
_check_config_spec(report, {"config_schema": manifest_config_schema(manifest)})
for server_name, config in package.mcp_servers.items():
if config.get("trust") == "untrusted":
report.add(f"server trust: {server_name}", True,
+194 -3
View File
@@ -14,9 +14,10 @@ from pathlib import Path
from typing import List, Tuple
# (rule, regex) applied to comment-stripped source; every hit fails the "desktop surface" check.
# ``X.prototype.y =`` is checked separately (``_foreign_prototype_writes``): it passes only where ``X``
# is provably the file's own class/function, because a bundled library setting fields on its own
# classes (three.js: ``Vector2.prototype.isVector2 = true``) is not reaching into the renderer.
_FORBIDDEN: tuple[tuple[str, re.Pattern[str]], ...] = (
("prototype patching",
re.compile(r"\b[A-Za-z_$][\w$]*\.prototype\.[\w$]+\s*=[^=]")),
("prototype patching",
re.compile(r"\bObject\.definePropert(?:y|ies)\(\s*[\w$.]+\.prototype\b")),
("prototype patching",
@@ -79,11 +80,201 @@ def _mask_regex_literals(source: str) -> str:
return _REGEXP_CTOR_MATCHER.sub(lambda m: " " * len(m.group(0)), masked)
_PROTOTYPE_WRITE = re.compile(r"\b([A-Za-z_$][\w$]*)\.prototype\.[\w$]+\s*=[^=]")
_RENDERER_GLOBAL_NAMES = (
r"Object|Function|Array|String|Number|Boolean|Symbol|Promise|RegExp|Date|Error|JSON|Map|Set"
r"|WeakMap|WeakSet|Proxy|Reflect|Storage|Window|Document|Node|EventTarget|Event|Location|History"
r"|Navigator|WebSocket|XMLHttpRequest|Request|Response|Headers|URL|Blob|File|FileReader"
r"|MutationObserver|Worker|BroadcastChannel|MessagePort|IDB\w*|\w*Element|\w*Event|CSS\w*")
# A regex cannot see scope, so a same-named local declaration elsewhere in the file must not excuse
# a write to a renderer global: these receivers always fail, declared or not.
_RENDERER_GLOBAL = re.compile(rf"^(?:{_RENDERER_GLOBAL_NAMES})$")
# A regex cannot see scope, and minifiers reuse one-letter names (three.js minified is
# ``class s{constructor(){s.prototype.isVector2=!0}}`` beside a dozen unrelated ``s=`` locals), so
# file-wide name checks cannot decide. The write passes only where ``X`` provably IS the class:
# inside the body of ``class X``/``function X``, or after such a declaration statement in the same
# block, with nothing rebinding ``X`` in between. ``window["Storage"]``, ``(0, window.Storage)`` and every
# other aliasing spelling then fail by default, wherever an unrelated ``class X`` lives.
_OWN_DECLARATION = r"(?:\bclass\s+{0}\b[^{{]*|\bfunction\s*\*?\s*{0}\s*\([^)]*\)\s*)\{{"
# ``var X = class {...}`` / ``X = function () {...}``: what bundlers emit; binds ``X`` in its block.
_ASSIGNED_DECLARATION = (r"(?<![\w$.]){0}\s*=\s*(?:class\b[^{{]*|function\s*\*?\s*[\w$]*\s*\([^)]*\)\s*)\{{")
# What can make ``X`` name something else, read liberally (a false hit only refuses a bundle for
# review; a miss would excuse a renderer patch): any assignment operator on ``X``; a declaration;
# ``X`` inside a destructuring pattern that is assigned or iterated (``({X} = o)``, ``for ([X] of
# a)``); a parameter (function, method, arrow, catch); ``for (X of/in …)``; an import binding.
# A reference (``register(X)``, ``[X, Y]``, ``{ Array: X }``) is not a binding.
_REBINDING = (
r"(?<![\w$.]){0}\s*(?:&&|\|\||\?\?|\*\*|<<|>>>?|[-+*/%&|^])?=(?![=>])"
r"|\b(?:let|const|var|class|function\s*\*?)\s+{0}(?![\w$])"
# a PATTERN opens where an expression cannot (not ``obj[`` / ``f()[``), names ``X`` itself as a
# target (not ``X.name``, not the key in ``{X: other}``), and is assigned or iterated
r"|(?:^|(?<=[;(,{{}}=?:&|!\n])|\b(?:let|const|var|return|yield|void))\s*[\[{{][^;=()]*?(?<![\w$.]){0}(?![\w$.]|\s*:)[^;=()]*?[}}\]][\s}}\]]*"
r"(?:=(?![=>])|\b(?:of|in)\b)"
r"|(?:\bfunction\s*\*?\s*[\w$]*|(?<![\w$.])(?!(?:if|for|while|switch|with|return|typeof|await)\b)[\w$]+)"
r"\s*\([^()]*?(?<![\w$.]){0}(?![\w$])[^()]*\)\s*\{{"
r"|\(([^()]*?(?<![\w$.]){0}(?![\w$])[^()]*)\)\s*=>|(?<![\w$.]){0}\s*=>"
r"|\bcatch\s*\([^)]*(?<![\w$.]){0}(?![\w$])"
r"|(?<![\w$.]){0}\s+(?:of|in)\b|\bas\s+{0}(?![\w$])|\bimport\s+{0}(?![\w$])")
_REGEX_AFTER_WORD = re.compile(r"(?:^|[^\w$.])(?:return|typeof|case|do|else|in|of|void|delete|throw|new|yield|await)\s*$")
_MAX_READINGS = 32
def _slash_readings(source: str, at: int) -> tuple[bool, bool]:
"""(may be a regex, may be division) for the ``/`` at *at* in code. After ``)`` or ``}`` both
are legal JavaScript (``if (x) /re/.test(s)`` vs ``(a) / b``); only a parser could tell."""
if source.startswith("/", at + 1):
return False, True
j = at - 1
while j >= 0 and source[j] in " \t\r\n":
j -= 1
if j < 0:
return True, False
prev = source[j]
if prev in ")}":
return True, True
if prev in "+-" and j > 0 and source[j - 1] == prev: # ``a++ / b``
return False, True
if prev in "(,=:[!&|?{;+-*%<>~^":
return True, False
keyword = _REGEX_AFTER_WORD.search(source, max(0, j - 12), j + 1)
if keyword: # an expression must follow ``return`` etc.; ``of`` may also be a plain name
return True, source[j - 1:j + 1] == "of"
return False, True
def _step(source: str, i: int, state: tuple) -> tuple[bool, list[tuple]]:
"""Advance one reading of the source by the character at *i*: (is that character literal text,
next readings). An empty list means this reading is not valid JavaScript (it dies)."""
kind, holes = state
c = source[i]
if kind in ("\"", "'"):
if c == "\\":
return True, [(kind + "\\", holes)]
if c == "\n":
return True, []
return c != kind, [("c" if c == kind else kind, holes)]
if kind in ("\"\\", "'\\", "t\\", "r\\", "k\\"):
return True, [(kind[0], holes)]
if kind == "t":
if c == "\\":
return True, [("t\\", holes)]
if c == "`":
return False, [("c", holes)]
if c == "$" and source.startswith("${", i):
return True, [("$", holes + (0,))]
return True, [("t", holes)]
if kind == "$": # the ``{`` of ``${``
return True, [("c", holes)]
if kind in ("r", "k"):
if c == "\n":
return True, []
if c == "\\":
return True, [(kind + "\\", holes)]
if kind == "r" and c == "/":
return False, [("c", holes)]
nxt = {"[": "k"} if kind == "r" else {"]": "r"}
return True, [(nxt.get(c, kind), holes)]
# code
if c in "\"'":
return False, [(c, holes)]
if c == "`":
return False, [("t", holes)]
if c == "/":
regex, division = _slash_readings(source, i)
return False, ([("r", holes)] if regex else []) + ([("c", holes)] if division else [])
if holes and c == "{":
return False, [("c", holes[:-1] + (holes[-1] + 1,))]
if holes and c == "}":
if holes[-1] == 0:
return True, [("t", holes[:-1])]
return False, [("c", holes[:-1] + (holes[-1] - 1,))]
return False, [("c", holes)]
def _code_view(source: str) -> str:
"""*source* with the text of quoted strings, template literals and regex literals blanked, same
length; a template's ``${…}`` stays code. Where a ``/`` could be either a regex or division,
BOTH readings are followed, and a character is blanked only when every live reading agrees it
is literal text. Since the true reading is always among them, executable code is never blanked
(a wrong guess cannot hide a patch); ambiguity only costs a refusal for manual review."""
out = list(source)
readings: set[tuple] = {("c", ())}
for i in range(len(source)):
literal_everywhere = True
nxt: set[tuple] = set()
for state in readings:
literal, follow = _step(source, i, state)
literal_everywhere &= literal
nxt.update(follow)
if literal_everywhere and source[i] != "\n":
out[i] = " "
if not nxt or len(nxt) > _MAX_READINGS: # beyond what we can follow: blank nothing more
break
readings = nxt
return "".join(out)
def _brace_pairs(code: str) -> dict[int, int]:
"""``{`` offset -> matching ``}`` offset in *code* (a :func:`_code_view`)."""
pairs: dict[int, int] = {}
stack: list[int] = []
for i, ch in enumerate(code):
if ch == "{":
stack.append(i)
elif ch == "}" and stack:
pairs[stack.pop()] = i
return pairs
def _enclosing_block_end(pairs: dict[int, int], at: int, size: int) -> int:
"""End of the innermost ``{...}`` containing *at* (``size`` at top level)."""
ends = [close for open_, close in pairs.items() if open_ < at < close]
return min(ends) if ends else size
def _own_class_write(source: str, name: str, at: int, pairs: dict[int, int]) -> bool:
"""The write is inside ``X``'s own body (any declaration form), or follows a ``class X`` /
``function X`` STATEMENT in the same block; either way with no rebinding of ``X`` in between."""
escaped = re.escape(name)
rebinding = re.compile(_REBINDING.format(escaped))
named = re.compile(_OWN_DECLARATION.format(escaped))
assigned = re.compile(_ASSIGNED_DECLARATION.format(escaped))
decls = sorted([(d, False) for d in named.finditer(source, 0, at)] + [(d, True) for d in assigned.finditer(source, 0, at)],
key=lambda item: item[0].start())
for decl, is_assignment in decls:
body = decl.end() - 1
close = pairs.get(body, -1)
if close > at:
if not rebinding.search(source, body + 1, at):
return True
continue
# A class EXPRESSION's own name is visible only in its body; a statement or ``X = class``
# binds ``X`` for the rest of its block.
statement = is_assignment or not re.search(
r"[=(,:?&|]\s*$|\breturn\s*$", source[max(0, decl.start() - 20):decl.start()])
if (statement and close != -1 and _enclosing_block_end(pairs, decl.start(), len(source)) > at
and not rebinding.search(source, close + 1, at)):
return True
return False
def _foreign_prototype_writes(source: str) -> list[int]:
"""Offsets of ``X.prototype.y =`` that are not provably on ``X``'s own class (see above)."""
code = _code_view(source) # offsets unchanged; string/template text never reads as code
writes = [(m.start(), m.group(1)) for m in _PROTOTYPE_WRITE.finditer(code)]
pairs = _brace_pairs(code) if writes else {}
return [at for at, name in writes
if _RENDERER_GLOBAL.match(name) or not _own_class_write(code, name, at, pairs)]
def desktop_surface_findings(source: str) -> list[tuple[str, int]]:
"""Return ``[(rule, line)]`` for every forbidden construct in a plugin.js source."""
stripped = _strip_comments(source)
no_regex = _mask_regex_literals(stripped)
findings: list[tuple[str, int]] = []
findings = [("prototype patching", stripped.count("\n", 0, at) + 1)
for at in _foreign_prototype_writes(stripped)]
for rule, pattern in _FORBIDDEN:
haystack = no_regex if rule in _MARKUP_RULES else stripped
for match in pattern.finditer(haystack):
+11 -1
View File
@@ -1063,6 +1063,16 @@ def _catalog():
return plugins_cmd_catalog
def _action_update(args):
if args.name:
if getattr(args, "yes", False):
_fail(_console(), "[red]Error:[/red] --yes only applies to `hermes plugins update` with no name.")
cmd_update(args.name)
return
from hermes_cli.plugins_cmd_update_all import cmd_update_all
cmd_update_all(yes=getattr(args, "yes", False))
def _action_pack(args):
from hermes_cli.plugin_packs import pack_command
pack_command(args)
@@ -1083,7 +1093,7 @@ _PLUGIN_ACTIONS = {
"browse": lambda args: _catalog().cmd_search(""),
"validate": lambda args: _catalog().cmd_validate(
args.path, as_json=getattr(args, "json", False), install_deps=getattr(args, "install_deps", False)),
"update": lambda args: cmd_update(args.name),
"update": _action_update,
"adopt": lambda args: cmd_adopt(args.name),
"trust-update-url": lambda args: cmd_trust_update_url(args.name),
"check-updates": lambda args: cmd_check_updates(args),
+9 -17
View File
@@ -497,11 +497,12 @@ class RepinResult(NamedTuple):
# new manifest adds (``desktop`` = a Desktop half appeared). Compared as sets — removals are not consent events.
_SURFACE_LABELS = {"capabilities": "host capabilities", "tools": "tools", "hooks": "hooks",
"python_dependencies": "Python dependencies", "desktop": "Desktop UI half",
"requires_auth": "your sign-in for"}
"requires_auth": "your sign-in for", "oauth": "your OAuth sign-in for"}
def plugin_surface(manifest: dict, tree: Path) -> dict[str, set]:
"""What an installed tree exposes: declared host capabilities, tools, hooks, Python deps, Desktop half."""
from hermes_cli.plugin_oauth import oauth_surface
from hermes_cli.plugins_cmd import _declared_capabilities_from_manifest
manifest = manifest or {}
@@ -516,6 +517,7 @@ def plugin_surface(manifest: dict, tree: Path) -> dict[str, set]:
"capabilities": set(_declared_capabilities_from_manifest(manifest, str(manifest.get("name") or "?"))),
"tools": _list("provides_tools"), "hooks": _list("provides_hooks", "hooks"),
"python_dependencies": _list("python_dependencies"), "requires_auth": _list("requires_auth"),
"oauth": oauth_surface(manifest),
"desktop": {"desktop/plugin.js"} if (tree / "desktop" / "plugin.js").is_file() else set(),
}
@@ -650,10 +652,11 @@ def repin_catalog_plugin(
return RepinResult(entry.sha, True, installed_name, warnings)
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, interactive: bool = True) -> None:
def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, interactive: bool = True) -> bool:
"""Re-pin a catalog install, printing progress; True when the pin moved. Refusals raise
:class:`RepinConsentRequired` / ``PluginOperationError`` with nothing changed (the caller reports them)."""
from hermes_cli.plugins_cmd import (
PluginOperationError, _ask_yes, _declared_capabilities_from_manifest, _fail, _is_tty, _read_manifest,
_run_capability_consent)
_ask_yes, _declared_capabilities_from_manifest, _is_tty, _read_manifest, _run_capability_consent)
console.print(f"[dim]Checking catalog pin for {name}...[/dim]")
def _confirm_widening(delta: dict[str, list[str]]) -> bool:
@@ -666,19 +669,7 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, inter
return False
return _ask_yes(" Apply this update? [y/N]: ")
try:
result = repin_catalog_plugin(
target,
sidecar,
interactive=interactive,
consent_cb=_confirm_widening,
)
except RepinConsentRequired as exc:
_fail(console, f"[yellow]Update of {name} not applied:[/yellow] {exc}")
raise SystemExit(1)
except PluginOperationError as exc:
_fail(console, f"[red]Error:[/red] {exc}")
raise SystemExit(1)
result = repin_catalog_plugin(target, sidecar, interactive=interactive, consent_cb=_confirm_widening)
verb = "updated to" if result.changed else "is already at catalog pin"
console.print(f"[green]✓[/green] Plugin [bold]{result.installed_name}[/bold] {verb} {result.sha[:8]}.")
for warning in result.warnings:
@@ -697,6 +688,7 @@ def cmd_update_catalog(name: str, target: Path, sidecar: dict, console, *, inter
console.print(
f"[yellow]Plugin {result.installed_name} has new capabilities; review them with "
f"`hermes plugins capabilities {result.installed_name}`.[/yellow]")
return result.changed
+3
View File
@@ -133,8 +133,11 @@ def _remove_user_plugin(plugins_dir: Path, name: str, target: Path) -> dict[str,
entry = next((e for e in _pc()._discover_all_plugins() if Path(str(e[4])) == target), None)
key = entry[5] if entry else target.name
aliases = _pc()._plugin_aliases(key) | {target.name}
from hermes_cli.plugin_oauth import forget_plugin_grants, plugin_key
grant_key = plugin_key(target) # grants are stored by directory key, which the manifest name may not match
_pc()._toggle_plugin_toolset(key, enable=False)
_remove_plugin_core(target)
forget_plugin_grants(grant_key) # a later plugin installed under the same key must not inherit the sign-in
return {"ok": True, "name": name, **_pc()._forget_plugin_config(aliases)}
+36 -22
View File
@@ -67,28 +67,27 @@ def _reclone_plugin_update(target: Path, source: str, previous_revision: object)
return f"Re-installed from {source}: {previous[:8]}..{revision[:8]}"
def cmd_update(name: str, *, interactive: bool = True) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
def update_one(name: str, target: Path, console, *, interactive: bool = True) -> bool:
"""Update one installed plugin, printing progress and consent prompts; True when it moved.
Refusals raise :class:`~hermes_cli.plugins_cmd_catalog.RepinConsentRequired` or
``PluginOperationError`` with nothing changed; :func:`cmd_update` turns them into exit 1 and
``hermes plugins update`` with no name records them per plugin and moves on.
"""
from rich.markup import escape
from hermes_cli import plugins_cmd_catalog as catalog
console = _pc()._console()
target = _pc()._require_installed_plugin(name, _pc()._plugins_dir(), console)
sidecar = catalog.catalog_install_record(target)
if sidecar: # catalog installs re-pin to the reviewed SHA — never `git pull`
catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive)
return
try:
output = _pull_plugin_update(
target,
lambda rec: (
f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run "
f"`hermes plugins install {escape(str(rec.get('source', '<source>')))} --force "
"--ref <40-character commit SHA>`."),
lambda: f"Plugin '{name}' was not installed from git (no .git directory). Cannot update.",
before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"),
interactive=interactive)
except _pc().PluginOperationError as exc:
_pc()._fail(console, f"[red]Error:[/red] {exc}")
return catalog.cmd_update_catalog(name, target, sidecar, console, interactive=interactive)
output = _pull_plugin_update(
target,
lambda rec: (
f"Plugin '{name}' is pinned to {rec.get('revision')}. To move it, run "
f"`hermes plugins install {escape(str(rec.get('source', '<source>')))} --force "
"--ref <40-character commit SHA>`."),
lambda: f"Plugin '{name}' was not installed from git (no .git directory). Cannot update.",
before_pull=lambda: console.print(f"[dim]Updating {name}...[/dim]"),
interactive=interactive)
_post_pull_housekeeping(target, console)
# Update-time re-consent (#64228): if the new version declares
@@ -110,9 +109,23 @@ def cmd_update(name: str, *, interactive: bool = True) -> None:
out = output.strip()
if "Already up to date" in out:
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] is already up to date.")
else:
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
console.print(f"[dim]{out}[/dim]")
return False
console.print(f"[green]✓[/green] Plugin [bold]{name}[/bold] updated.")
console.print(f"[dim]{out}[/dim]")
return True
def cmd_update(name: str, *, interactive: bool = True) -> None:
"""``hermes plugins update <name>``: :func:`update_one`, exit 1 on a refusal."""
from hermes_cli.plugins_cmd_catalog import RepinConsentRequired
console = _pc()._console()
target = _pc()._require_installed_plugin(name, _pc()._plugins_dir(), console)
try:
update_one(name, target, console, interactive=interactive)
except RepinConsentRequired as exc:
_pc()._fail(console, f"[yellow]Update of {name} not applied:[/yellow] {exc}")
except _pc().PluginOperationError as exc:
_pc()._fail(console, f"[red]Error:[/red] {exc}")
def _post_pull_housekeeping(target: Path, console) -> None:
@@ -269,7 +282,8 @@ def cmd_check_updates(args: Any | None = None) -> None:
console.print()
console.print(table)
console.print()
console.print("[dim]Check-only. Apply with: hermes plugins update <name>[/dim]")
console.print("[dim]Check-only. Apply with: hermes plugins update <name>, or "
"hermes plugins update to update them all[/dim]")
def dashboard_update_user_plugin(name: str, *, accept_capabilities: bool = False) -> dict[str, Any]:
+148
View File
@@ -0,0 +1,148 @@
"""``hermes plugins update`` with no name: update every installed plugin that has an update.
The list comes from the read-only ``check-updates`` probe (:func:`hermes_cli.plugins_updates.run_checks`);
each plugin is then updated through the single-plugin
path (:func:`~hermes_cli.plugins_cmd_update.update_one`), so catalog consent, the security scan and
capability re-consent still apply per plugin.
Sibling of :mod:`hermes_cli.plugins_cmd` (the facade is imported late, never at module level).
"""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass, field
from typing import Any
# Only these classes have an update path `hermes plugins update <name>` can drive: a catalog install
# re-pins to the reviewed sha, a tracked git install pulls. A pip row is a PyPI distribution that
# Hermes never upgrades (#132494), so offering it would promise an update nothing applies.
_UPDATE_KIND = {"catalog": "catalog re-pin", "git": "git pull"}
logger = logging.getLogger(__name__)
def _pc():
from hermes_cli import plugins_cmd
return plugins_cmd
@dataclass
class UpdatePlan:
"""What ``update`` with no name would do: *movable* rows it updates, *skipped* ``(row, reason)``
pairs it cannot move, *current* the count already up to date."""
movable: list[Any] = field(default_factory=list)
skipped: list[tuple[Any, str]] = field(default_factory=list)
current: int = 0
def plan_update_all(results: list[Any]) -> UpdatePlan:
"""Split ``run_checks`` rows into the plugins ``update`` can move and the ones it cannot (with why)."""
plan = UpdatePlan()
for row in results:
if row.needs_fixing:
plan.skipped.append((row, f"needs fixing: {row.needs_fixing}"))
elif row.update_available is True and row.klass in _UPDATE_KIND:
plan.movable.append(row)
elif row.update_available is True:
plan.skipped.append((row, f"{row.klass} distribution; Hermes does not upgrade it"))
elif row.update_available is None:
plan.skipped.append((row, row.reason or "update status unknown"))
elif row.reason: # up to date by construction but worth naming (pinned)
plan.skipped.append((row, row.reason))
else:
plan.current += 1
return plan
def _short(reason: str) -> str:
first = reason.strip().splitlines()[0] if reason.strip() else ""
return first if len(first) <= 200 else first[:197] + "..."
def _update_one(row, console, *, interactive: bool) -> tuple[str, str]:
"""Run the single-plugin update for one plan row; return ``(status, detail)``, status being
updated/current/failed. A refusal is printed like ``update <name>`` prints it, then recorded."""
from rich.markup import escape
from hermes_cli.plugins_cmd_catalog import RepinConsentRequired
from hermes_cli.plugins_cmd_update import update_one
target = _pc()._user_installed_plugin_dir(row.name)
if target is None:
return "failed", "no longer installed"
try:
moved = update_one(row.name, target, console, interactive=interactive)
except RepinConsentRequired as exc:
console.print(f"[yellow]Update of {escape(row.name)} not applied:[/yellow] {escape(str(exc))}")
return "failed", _short(f"not applied: {exc}")
except _pc().PluginOperationError as exc:
console.print(f"[red]Error:[/red] {escape(str(exc))}")
return "failed", _short(str(exc))
except Exception as exc: # per-plugin batch boundary: a feed timeout or crash must not stop the rest
logger.warning("update of plugin %s failed", row.name, exc_info=True)
console.print(f"[red]Error:[/red] {escape(type(exc).__name__)}: {escape(str(exc))}")
return "failed", _short(f"{type(exc).__name__}: {exc}")
if not moved:
return "current", ""
return "updated", f"{(row.current or '')[:8]}..{(row.latest or '')[:8]}"
def _print_plan(console, plan: UpdatePlan) -> None:
from rich.markup import escape
table = _pc()._table([("#", "dim"), ("Plugin", "bold"), ("Current", ""), ("Latest", ""), ("Update", "")],
title="Plugin updates available")
for i, row in enumerate(plan.movable, 1):
table.add_row(str(i), row.name, (row.current or "-")[:12], (row.latest or "-")[:12], _UPDATE_KIND[row.klass])
console.print()
console.print(table)
for row, reason in plan.skipped:
console.print(f" [dim]skipped {escape(row.name)} ({row.klass}): {escape(reason)}[/dim]")
def cmd_update_all(*, yes: bool = False) -> None:
"""``hermes plugins update [--yes]``: list the plugins with an update and update them all.
A terminal asks once (``Update all N? [Y/n]``); without one nothing moves unless ``--yes`` is
given. Each plugin then goes through ``update_one``, interactive only when a terminal is present,
so per-plugin consent prompts still appear there and stay closed (fail closed) under ``--yes``
in scripts. One failure does not stop the rest; exit 1 when any update failed.
"""
from rich.markup import escape
from hermes_cli.plugins_updates import run_checks
console = _pc()._console()
console.print("[dim]Checking installed plugins for updates...[/dim]")
plan = plan_update_all(run_checks(_pc()._plugins_dir()))
if not plan.movable:
for row, reason in plan.skipped:
console.print(f" [dim]skipped {escape(row.name)} ({row.klass}): {escape(reason)}[/dim]")
console.print("[green]✓[/green] No plugin updates to apply.")
return
_print_plan(console, plan)
count = len(plan.movable)
tty = _pc()._is_tty()
if not yes:
if not tty:
_pc()._fail(console, f"[yellow]Not updating: no terminal to confirm. Re-run with --yes to update "
f"these {count} plugin(s).[/yellow]")
try:
answer = input(f"\nUpdate all {count}? [Y/n]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer not in ("", "y", "yes"):
console.print("Cancelled. Nothing was updated.")
return
outcomes = []
for row in plan.movable:
console.print(f"\n[bold]── {row.name}[/bold]")
outcomes.append((row.name, *_update_one(row, console, interactive=tty)))
console.print("\n[bold]Summary[/bold]")
marks = {"updated": "[green]✓ updated[/green]", "current": "[dim]= already current[/dim]",
"failed": "[red]✗ failed[/red]"}
for name, status, detail in outcomes:
console.print(f" {marks[status]} {escape(name)}" + (f" [dim]{escape(detail)}[/dim]" if detail else ""),
highlight=False)
if any(status == "failed" for _, status, _ in outcomes):
sys.exit(1)
+24 -18
View File
@@ -35,7 +35,7 @@ _KNOWN_MANIFEST_FIELDS: set[str] = {
"pip_dependencies", "provides_browser_providers", "provides_web_providers",
"manifest_version", "api_version", "requires_plugins", "python_dependencies", "config_schema",
"license", "homepage", "tags", "capabilities", "emits", "listens", "hermes", "depends",
"requires_hermes", "python_runtime", "provides_locales", "requires_auth",
"requires_hermes", "python_runtime", "provides_locales", "requires_auth", "oauth",
}
# Highest manifest schema version this Hermes understands.
@@ -126,6 +126,26 @@ def _manifest_int(raw: object, key: str, warn: str, fallback: Optional[int]) ->
return fallback
def _config_schema_entries(key: str, raw: Any) -> dict[str, Any]:
"""``config_schema`` (key -> {type?, default?, description?, ...}) keeping only mapping entries;
warns on what it drops or cannot type-check. Shared by ``plugin.yaml`` and ``plugin.json``."""
schema: dict[str, Any] = {}
for skey, spec in (raw if isinstance(raw, Mapping) else {}).items():
if not isinstance(spec, Mapping):
logger.warning(
"Plugin %s: config_schema entry %r must be a mapping (e.g. {type: str}); skipping", key, skey)
continue
stype = spec.get("type")
if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES:
logger.warning(
"Plugin %s: config_schema key %r declares unknown type %r "
"(known: %s); type check will be skipped for it",
key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)),
)
schema[str(skey)] = dict(spec)
return schema
def _parse_manifest_v2_fields(data: Mapping, key: str) -> dict[str, Any]:
"""Validate/normalize manifest v2 fields into PluginManifest kwargs (warnings, never failures).
@@ -153,22 +173,7 @@ def _parse_manifest_v2_fields(data: Mapping, key: str) -> dict[str, Any]:
lambda item: item.strip() if isinstance(item, str) and item.strip() else None,
"Plugin %s: python_dependencies entry %r must be a non-empty requirement string; skipping",
)
# config_schema — mapping of key -> {type?, default?, description?, required?}.
schema: dict[str, Any] = {}
raw_schema = _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping")
for skey, spec in (raw_schema or {}).items():
if not isinstance(spec, Mapping):
logger.warning(
"Plugin %s: config_schema entry %r must be a mapping (e.g. {type: str}); skipping", key, skey)
continue
stype = spec.get("type")
if stype is not None and str(stype).lower() not in _CONFIG_SCHEMA_TYPES:
logger.warning(
"Plugin %s: config_schema key %r declares unknown type %r "
"(known: %s); type check will be skipped for it",
key, skey, stype, ", ".join(sorted(_CONFIG_SCHEMA_TYPES)),
)
schema[str(skey)] = dict(spec)
schema = _config_schema_entries(key, _manifest_field_of_type(data, key, "config_schema", Mapping, "a mapping"))
tags = [str(t) for t in (_manifest_field_of_type(data, key, "tags", list, "a list") or [])]
# Forward compat: unknown fields warn (never fail); v1 manifests only at debug.
unknown = sorted(set(data.keys()) - _KNOWN_MANIFEST_FIELDS)
@@ -488,7 +493,7 @@ def requires_hermes_error(manifest: PluginManifest) -> Optional[str]:
def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginManifest:
"""Build the manifest for a portable Agent Plugin directory (``plugin.json``); diagnostics warn."""
from hermes_cli.agent_plugins import read_agent_plugin_manifest
from hermes_cli.agent_plugins import manifest_config_schema, read_agent_plugin_manifest
data, diagnostics = read_agent_plugin_manifest(child)
for diagnostic in diagnostics:
logger.warning("Agent Plugin '%s': %s", child, diagnostic.message)
@@ -497,6 +502,7 @@ def portable_plugin_manifest(child: Path, source: str, prefix: str) -> PluginMan
name=data["name"], version=data.get("version", ""), description=data.get("description", ""),
author=_display_author(data.get("author", "")), source=source, path=str(child), key=key,
portable=True, skill_namespace=_portable_skill_namespace(key),
config_schema=_config_schema_entries(key, manifest_config_schema(data)),
)
+20 -8
View File
@@ -36,24 +36,36 @@ _ENV_NAME_CLEAN_RE = re.compile(r"[^A-Z0-9]+")
def _manifest_config_schema(plugin_dir: Optional[Path]) -> Mapping[str, Mapping[str, Any]]:
"""``config_schema`` mapping from ``<plugin_dir>/plugin.yaml``; ``{}`` when absent or malformed
(the loader already warned about malformed entries at load time)."""
"""``config_schema`` from ``<plugin_dir>/plugin.yaml``, or for a portable package from
``plugin.json`` ``extensions.com.nousresearch.hermes.config_schema`` (Agent Plugins v1 forbids
unknown top-level fields, so Hermes-specific keys live in the vendor namespace); ``{}`` when
absent or malformed (the loader already warned about malformed entries at load time)."""
if plugin_dir is None:
return {}
manifest = Path(plugin_dir) / "plugin.yaml"
if not manifest.is_file():
return {}
try:
from utils import fast_safe_load
data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) or {}
if manifest.is_file():
from utils import fast_safe_load
data = fast_safe_load(manifest.read_text(encoding="utf-8-sig")) or {}
raw = data.get("config_schema") if isinstance(data, Mapping) else None
else:
raw = _portable_config_schema(Path(plugin_dir))
except Exception as exc: # unreadable manifest: no settings surface, never a failed list
logger.debug("plugin settings: cannot read %s: %s", manifest, exc)
logger.debug("plugin settings: cannot read the manifest in %s: %s", plugin_dir, exc)
return {}
raw = data.get("config_schema") if isinstance(data, Mapping) else None
if not isinstance(raw, Mapping):
return {}
return {str(k): v for k, v in raw.items() if isinstance(v, Mapping)}
def _portable_config_schema(plugin_dir: Path) -> Any:
portable = plugin_dir / "plugin.json"
if not portable.is_file():
return None
import json
from hermes_cli.agent_plugins import manifest_config_schema
return manifest_config_schema(json.loads(portable.read_text(encoding="utf-8-sig")))
def secret_env_name(plugin_id: str, key: str, spec: Mapping[str, Any]) -> str:
"""``.env`` variable a ``type: secret`` field is stored under: the manifest's ``env:`` or
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more