diff --git a/.gitignore b/.gitignore index f3f0e4495d..ef15f20994 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,7 @@ run_datagen_sonnet.sh source-data/* run_datagen_megascience_glm4-6.sh data/* +MagicMock/ # No trailing slash: also matches node_modules SYMLINKS (worktrees often # symlink node_modules to the main checkout; the dir-only pattern let one # slip into a commit and break `npm ci` on CI with ENOTDIR). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5ca5a0f1c..4179d937ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -456,8 +456,6 @@ prerequisites: # Optional legacy runtime requirements commands: [curl, jq] # Advisory only; does not hide the skill metadata: hermes: - editorial_name: My Skill # Optional human-readable UI title - editorial_description: What this skill helps a person accomplish. tags: [Category, Subcategory, Keywords] related_skills: [other-skill-name] fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable @@ -490,12 +488,6 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` -`metadata.hermes.editorial_name` and `editorial_description` are optional, -human-facing presentation copy. They may use natural titles and fuller prose -than the routing-focused top-level fields. Hermes continues to identify and -route skills with `name` and `description`; UIs fall back to that canonical -pair when editorial copy is absent. - ### Platform-specific skills Skills can declare which OS platforms they support via the `platforms` frontmatter field. Skills with this field are automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. diff --git a/MagicMock/mock._session_db.db_path/126402682339024 b/MagicMock/mock._session_db.db_path/126402682339024 deleted file mode 100644 index 2cc4d1be35..0000000000 Binary files a/MagicMock/mock._session_db.db_path/126402682339024 and /dev/null differ diff --git a/MagicMock/mock._session_db.db_path/126402682339024.fts_rebuild.lock b/MagicMock/mock._session_db.db_path/126402682339024.fts_rebuild.lock deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/MagicMock/mock._session_db.db_path/126402682339024.quarantine.lock b/MagicMock/mock._session_db.db_path/126402682339024.quarantine.lock deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/MagicMock/mock._session_db.db_path/126402702293264 b/MagicMock/mock._session_db.db_path/126402702293264 deleted file mode 100644 index e93b5fe5a9..0000000000 Binary files a/MagicMock/mock._session_db.db_path/126402702293264 and /dev/null differ diff --git a/MagicMock/mock._session_db.db_path/126402702293264.fts_rebuild.lock b/MagicMock/mock._session_db.db_path/126402702293264.fts_rebuild.lock deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/MagicMock/mock._session_db.db_path/126402702293264.quarantine.lock b/MagicMock/mock._session_db.db_path/126402702293264.quarantine.lock deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3ad9b436be..bbf640f66d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -910,6 +910,15 @@ _NOUS_MODEL = "google/gemini-3.6-flash" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" +_AUTH_JSON_PATH_AT_IMPORT = _AUTH_JSON_PATH + + +def _auth_json_path(): + """Active profile's ``auth.json`` at call time (a patched ``_AUTH_JSON_PATH`` still wins). The + import-time constant is the LAUNCH profile's; under multiplexing a secondary's auxiliary calls + would otherwise authenticate to Nous with the default profile's token.""" + from hermes_cli.auth import _auth_file_path + return _AUTH_JSON_PATH if _AUTH_JSON_PATH != _AUTH_JSON_PATH_AT_IMPORT else _auth_file_path() # Hosts exposing BOTH ``…/anthropic`` and a sibling OpenAI ``…/v1``. Matched on the URL *host* # only: unconditional rewrites break Anthropic-only gateways. @@ -1048,7 +1057,7 @@ def _nous_min_key_ttl_seconds() -> int: def _scoped_key_env(name: str) -> str: - """Read a provider API key env var through the profile secret scope. + """Read a provider API key (or its paired base-URL) env var through the profile secret scope. In agent turns the scope's verdict is authoritative (a scoped miss must not borrow another profile's key); unscoped startup/CLI paths fall back to os.environ. @@ -1859,9 +1868,10 @@ def _read_nous_auth() -> Optional[dict]: "source": "pool", } try: - if not _AUTH_JSON_PATH.is_file(): + auth_path = _auth_json_path() + if not auth_path.is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8-sig")) + data = json.loads(auth_path.read_text(encoding="utf-8-sig")) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) @@ -1971,8 +1981,8 @@ def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]: ).strip() _url = lambda v: str(v or "").strip().rstrip("/") # noqa: E731 base_url = _xai_validate_inference_base_url( - _url(os.getenv("HERMES_XAI_BASE_URL", "")) - or _url(os.getenv("XAI_BASE_URL", "")) + _url(_scoped_key_env("HERMES_XAI_BASE_URL")) + or _url(_scoped_key_env("XAI_BASE_URL")) or _url(getattr(entry, "runtime_base_url", None)) or _url(getattr(entry, "base_url", None)), fallback=DEFAULT_XAI_OAUTH_BASE_URL, @@ -2246,7 +2256,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: _mark_provider_unhealthy("nous", ttl=60) return None, None base_url = str( - (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) + (nous or {}).get("inference_base_url") or _scoped_key_env("NOUS_INFERENCE_BASE_URL") or _NOUS_DEFAULT_BASE_URL ).rstrip("/") lane = "vision" if vision else "text" # The free tier's host serves exactly one model, for every lane: asking it for the Portal's @@ -2632,7 +2642,8 @@ def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[st logger.debug("Auxiliary client: custom runtime resolution failed: %s", exc) runtime = None if not isinstance(runtime, dict): - openai_base = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") + # Base URL is per-profile like the key one line below (a scoped key must not hit the default's proxy). + openai_base = _scoped_key_env("OPENAI_BASE_URL").rstrip("/") if not openai_base: return None, None, None runtime = {"base_url": openai_base, "api_key": _scoped_key_env("OPENAI_API_KEY")} @@ -5605,7 +5616,7 @@ def _expand_direct_api_alias(prov: Optional[str], existing_base: Optional[str]) from hermes_cli.runtime_provider import _get_named_custom_provider if _get_named_custom_provider(prov) is not None: return prov, existing_base - return "custom", existing_base or os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/") or target_base + return "custom", existing_base or _scoped_key_env("OPENAI_BASE_URL").rstrip("/") or target_base def _preserve_provider_with_base_url(prov: Optional[str]) -> bool: diff --git a/agent/i18n.py b/agent/i18n.py index b6cec724e2..d1e7258550 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -118,9 +118,11 @@ def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None: out[prefix] = node -@lru_cache(maxsize=1) -def _config_language_cached() -> str | None: - """``display.language`` from config.yaml, read once per process (``t()`` is a hot path).""" +@lru_cache(maxsize=8) +def _config_language_cached(hermes_home: str) -> str | None: + """``display.language`` from config.yaml, read once per profile home (``t()`` is a hot path). + Keyed by home so a multiplexed gateway serving several profiles doesn't freeze the first + profile's language for every other profile.""" try: from hermes_cli.config import load_config_readonly lang = (load_config_readonly().get("display") or {}).get("language") @@ -130,6 +132,11 @@ def _config_language_cached() -> str | None: return None +def _config_language() -> str | None: + from hermes_constants import get_hermes_home + return _config_language_cached(str(get_hermes_home())) + + def reset_language_cache() -> None: """Invalidate cached language resolution and catalogs (call after ``save_config`` changes ``display.language``).""" _config_language_cached.cache_clear() @@ -138,9 +145,15 @@ def reset_language_cache() -> None: def get_language() -> str: - """Resolve the active language using env > config > default order.""" - env_lang = os.environ.get("HERMES_LANGUAGE") - return _normalize_lang(env_lang) if env_lang else _config_language_cached() or DEFAULT_LANGUAGE + """Resolve the active language using env > config > default order. ``HERMES_LANGUAGE`` is a + per-profile ``.env`` value, so it is read through the secret scope: under multiplexing a raw + environ read would impose the default profile's language on every other profile.""" + from agent.secret_scope import UnscopedSecretError, get_secret + try: + env_lang = get_secret("HERMES_LANGUAGE") + except UnscopedSecretError: + env_lang = os.environ.get("HERMES_LANGUAGE") # unscoped default-profile path: environ IS its own value + return _normalize_lang(env_lang) if env_lang else _config_language() or DEFAULT_LANGUAGE def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str: diff --git a/agent/inline_tool_executors.py b/agent/inline_tool_executors.py index e1d5d1ad67..98b9ef85ea 100644 --- a/agent/inline_tool_executors.py +++ b/agent/inline_tool_executors.py @@ -104,7 +104,8 @@ def _session_search(agent, args: dict, ctx: InlineToolContext) -> Any: ( ("query", "query", ""), ("role_filter", "role_filter"), ("limit", "limit", 3), ("session_id", "session_id"), ("around_message_id", "around_message_id"), - ("window", "window", 5), ("sort", "sort"), ("detail", "detail", "adaptive"), + ("window", "window", 5), ("sort", "sort"), ("profile", "profile"), + ("detail", "detail", "adaptive"), ), db=session_db, current_session_id=agent.session_id, ) diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py index 676007af7f..db50f95aa3 100644 --- a/agent/learn_prompt.py +++ b/agent/learn_prompt.py @@ -38,12 +38,6 @@ Frontmatter: cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only when the dependency is genuinely platform-bound. Omit the field for portable skills. -- metadata.hermes.editorial_name: a concise, human-readable title for app - surfaces. Use normal title casing and spaces; this is presentation copy, not - the agent-facing skill identifier. -- metadata.hermes.editorial_description: one or two plain-language sentences - explaining the skill to a person browsing it. This is presentation copy and - does not replace the routing-focused top-level description. - metadata.hermes.tags: a few Capitalized, Relevant, Tags. Body section order (omit a section only if it genuinely has no content): diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 2a293f583f..25192d59cb 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -1255,6 +1255,9 @@ _PRE_CATALOG_STALE_KEYS = frozenset({ "grok-4.3", "grok-4.6", # 1M / 500K; "grok-4" catch-all persisted 256,000 "grok-4-fast", "grok-4.20", # 2M; fell through to the 256K fallback "qwen3.6-plus", # 1M; "qwen" catch-all persisted 131,072 + # V4 / V4.1 Flash: 1M. Pre-entry builds matched the family catch-all and persisted 128K. + "deepseek-flash", "deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-pro", + "deepseek-chat", "deepseek-reasoner", }) diff --git a/agent/models_dev.py b/agent/models_dev.py index 75ed5d3327..1a3c156152 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -525,6 +525,14 @@ _UNKNOWN_MODEL_BASE: Dict[str, Any] = {"limit": {"context": 200000, "output": 81 # Account-gated models may be usable before models.dev has indexed them. Keep # their capabilities available for an explicitly selected/discovered model # without adding them to any picker catalog. +_DEEPSEEK_FLASH_VISION: Dict[str, Any] = { + "limit": {"context": 1_000_000, "output": 384_000}, + "modalities": {"input": ["text", "image"], "output": ["text"]}, + "tool_call": True, + "reasoning": True, + "family": "deepseek-flash", +} + _BUILTIN_MODEL_METADATA: Dict[Tuple[str, str], Dict[str, Any]] = { ("openai", "gpt-6-astra"): { "limit": {"context": 1_050_000, "output": 128_000}, @@ -533,6 +541,14 @@ _BUILTIN_MODEL_METADATA: Dict[Tuple[str, str], Dict[str, Any]] = { "reasoning": True, "family": "gpt-6", }, + # Native DeepSeek V4.1-Flash is multimodal (https://api-docs.deepseek.com/guides/vision). + # models.dev lagged the 2026-09-10 rename; without this, a cold/empty cache treats + # ``deepseek-flash`` as unknown → image_input_mode falls through to lossy text. + # ``deepseek-v4-pro`` stays catalog-only: vendor docs still mark it text-only. + ("deepseek", "deepseek-flash"): _DEEPSEEK_FLASH_VISION, + ("deepseek", "deepseek-v4-flash"): _DEEPSEEK_FLASH_VISION, + ("deepseek", "deepseek-v4.1-flash"): _DEEPSEEK_FLASH_VISION, + ("deepseek", "deepseek-v4-flash-vision-exp"): _DEEPSEEK_FLASH_VISION, } diff --git a/agent/outbound_webhooks.py b/agent/outbound_webhooks.py index 2a637bae24..a7df914645 100644 --- a/agent/outbound_webhooks.py +++ b/agent/outbound_webhooks.py @@ -14,7 +14,6 @@ import hashlib import hmac import json import logging -import os import queue import re import threading @@ -201,10 +200,14 @@ def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]: warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS) timeout = DEFAULT_TIMEOUT_SECONDS name = raw.get("name") - # ``secret_env`` (env var name, preferred) wins over inline ``secret``. + # ``secret_env`` (env var name, preferred) wins over inline ``secret``. Read through the profile + # secret scope: the gateway registers each multiplexed profile's targets inside that profile's + # scope, and a raw environ read would sign a secondary's deliveries with the DEFAULT profile's + # secret (or leave them unsigned when the var lives only in the secondary's .env). secret_env = raw.get("secret_env") if isinstance(secret_env, str) and secret_env.strip(): - secret = os.environ.get(secret_env.strip(), "") or None + from agent.secret_scope import get_secret + secret = get_secret(secret_env.strip(), "") or None if secret is None: warn(".secret_env=%r is not set in the environment — deliveries will be UNSIGNED", secret_env.strip()) else: diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index a204ec5b76..7c0b78d2ea 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -922,7 +922,14 @@ class RelaySessionCoordinator: return host.register_subagent(event, metadata=metadata) return host.ensure_session({"session_id": session_id}, metadata=metadata) - def begin_turn(self, lease: ConversationLease, *, turn_id: str, task_id: str) -> RelayTurnContext: + def begin_turn( + self, + lease: ConversationLease, + *, + turn_id: str, + task_id: str, + metadata: dict[str, Any] | None = None, + ) -> RelayTurnContext: if lease.released: raise RuntimeError("Hermes Relay conversation lease is released") turn = RelayTurnContext(lease=lease, turn_id=turn_id, task_id=task_id) @@ -942,10 +949,17 @@ class RelaySessionCoordinator: if host is not None: # Rotation happens HERE: no live turn scope on the stack, so the session scope can close/reopen LIFO. _warn_on_error("segment rotation", self._maybe_rotate_segment, host, lease.session) + turn_metadata = dict(metadata or {}) + turn_metadata.update( + runtime_metadata( + host.runtime_id, + **{"hermes.execution_surface": lease.platform or "unknown"}, + ) + ) turn.handle = _warn_on_error( "turn initialization", host.run_in_session, lease.session, host.relay.scope.push, TURN_SCOPE, host.relay.ScopeType.Function, handle=lease.session.handle, input={}, - metadata=runtime_metadata(host.runtime_id, **{"hermes.execution_surface": lease.platform or "unknown"}), + metadata=turn_metadata, timeout=_SCOPE_OP_TIMEOUT, ) turn._previous_turn = _CURRENT_TURN.get() diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index f55ae91db1..d9eb217a96 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -83,11 +83,14 @@ def _payload_fields(kwargs: Dict[str, Any]) -> Dict[str, Any]: cwd = str(Path.cwd()) except OSError: cwd = "" + from hermes_cli.profiles import get_active_profile_name return { "tool_name": kwargs.get("tool_name"), "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None, "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "", "cwd": cwd, + # Resolved at fire time: a multiplexed gateway's hook script must know which profile fired it. + "profile": get_active_profile_name(), "extra": {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS}, } @@ -301,11 +304,15 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]: # Own process group on POSIX so a timed-out hook's descendants are reaped with it (Windows: kill_process_tree # / taskkill /T). Hooks that finish in time keep detached helpers alive. popen_kwargs: Dict[str, Any] = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0} - from agent.delegation_context import delegated_child_subprocess_env + # HERMES_HOME follows the routed profile (the import-time environ holds the launch profile's), and + # under multiplexing os.environ carries the DEFAULT profile's secrets, which a secondary's hook + # script must not inherit; single-profile runs keep the process env byte-for-byte as before. + from agent.secret_scope import is_multiplex_active + from tools.environments.local import build_subprocess_env try: proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', errors='replace', shell=False, - env=delegated_child_subprocess_env(), **popen_kwargs) + env=build_subprocess_env(scrub_secrets=is_multiplex_active()), **popen_kwargs) except Exception as exc: return failed(next((msg for cls, msg in _POPEN_ERRORS if isinstance(exc, cls)), str(exc))) try: diff --git a/agent/skill_utils.py b/agent/skill_utils.py index fe783cd52a..8b6b6124d0 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -33,12 +33,6 @@ ORG_ACTIVE_MARKER = ".active_org" ORG_PROVENANCE_FILE = ".org-provenance.json" ORG_BASELINE_FILE = ".org-baseline.json" # upstream fingerprint; detects local edits -# Collective Wisdom managed installs are intentionally separate from the M2 -# whole-org mirror. The only writer of this marker is the Wisdom setup/client -# path after the Gateway has accepted the profile's installation identity. -WISDOM_MANAGED_DIR_NAME = "_wisdom" -WISDOM_ACTIVE_MARKER = ".active_org" - def read_active_org_id(skills_dir: Path) -> Optional[str]: """The org id whose mirror may resolve, or None (no org skills load).""" @@ -49,27 +43,6 @@ def read_active_org_id(skills_dir: Path) -> Optional[str]: return None -def read_active_wisdom_org_id(skills_dir: Path) -> Optional[str]: - """The last Gateway-verified org whose managed Wisdom skills may load.""" - try: - marker = skills_dir / WISDOM_MANAGED_DIR_NAME / WISDOM_ACTIVE_MARKER - if not marker.exists(): - return None - value = marker.read_text(encoding="utf-8").strip() - return value or None - except OSError: - return None - - -def is_wisdom_managed_path(path, skills_dir: Path) -> bool: - """True when *path* is below ``_wisdom//``.""" - try: - rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) - except (OSError, ValueError): - return False - return bool(rel.parts) and rel.parts[0] == WISDOM_MANAGED_DIR_NAME - - def _org_rel_parts(path, skills_dir: Path) -> Tuple[str, ...]: """Path parts of *path* relative to *skills_dir* if it is under ``_org/``, else ``()``.""" try: @@ -759,113 +732,22 @@ def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bo return len(_normalize_skill_description(frontmatter)) > SKILL_PROMPT_DESC_LIMIT -def extract_skill_editorial_metadata( - frontmatter: Dict[str, Any], - *, - fallback_name: str, - fallback_description: str, -) -> Dict[str, str]: - """Resolve optional human-facing skill copy without changing agent metadata. - - ``name`` and ``description`` remain the canonical agent-facing routing - fields. Hermes UIs may use the optional values under ``metadata.hermes``; - older and third-party skills fall back to the canonical pair. - """ - metadata = frontmatter.get("metadata") - hermes = metadata.get("hermes") if isinstance(metadata, dict) else None - if not isinstance(hermes, dict): - hermes = {} - - editorial_name = hermes.get("editorial_name") - editorial_description = hermes.get("editorial_description") - return { - "editorial_name": ( - editorial_name.strip() - if isinstance(editorial_name, str) and editorial_name.strip() - else fallback_name - ), - "editorial_description": ( - editorial_description.strip() - if isinstance(editorial_description, str) - and editorial_description.strip() - else fallback_description - ), - } - - -def load_skill_editorial_metadata( - skill_path: Path, - *, - fallback_name: str | None = None, - fallback_description: str = "", -) -> Dict[str, str]: - """Load human-facing copy from a skill directory with safe fallbacks.""" - canonical_name = fallback_name or skill_path.name - canonical_description = fallback_description - try: - frontmatter, _body = parse_frontmatter( - (skill_path / "SKILL.md").read_text(encoding="utf-8") - ) - name = frontmatter.get("name") - description = frontmatter.get("description") - if isinstance(name, str) and name.strip(): - canonical_name = name.strip() - if isinstance(description, str) and description.strip(): - canonical_description = description.strip() - return extract_skill_editorial_metadata( - frontmatter, - fallback_name=canonical_name, - fallback_description=canonical_description, - ) - except (OSError, UnicodeError, ValueError): - return { - "editorial_name": canonical_name, - "editorial_description": canonical_description, - } - - -# ── File iteration ──────────────────────────────────────────────────────── - - def iter_skill_index_files(skills_dir: Path, filename: str): - """Walk skills_dir yielding sorted paths matching *filename*. - - Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill - support directories. Support directories (references/templates/assets/ - scripts) can contain arbitrary markdown and even archived package - ``SKILL.md`` files, but they are progressive-disclosure data loaded through - ``skill_view(..., file_path=...)`` rather than active skill roots. - - M2 org mirrors (``_org/``) and Collective Wisdom installs - (``_wisdom/``): TOKEN-GATED resolution. Only the active org's - subdir (per the sync-client-written ``.active_org`` marker) is walked; - every other ``_org//`` (stale mirror from a previous org, or no - marker at all) is pruned — leave an org and its skills stop resolving, - without any manual cleanup. - """ + """Walk skills_dir yielding sorted paths matching *filename*; prunes + EXCLUDED_SKILL_DIRS and support dirs of skill roots. Org mirrors are + TOKEN-GATED: only the active org's subdir is walked, so leaving an org + stops its skills resolving without manual cleanup.""" skills_dir_str = str(skills_dir) active_org = read_active_org_id(skills_dir) - active_wisdom_org = read_active_wisdom_org_id(skills_dir) org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) - wisdom_root = os.path.join(skills_dir_str, WISDOM_MANAGED_DIR_NAME) matches: list[str] = [] for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: dirs.remove(ORG_MIRROR_DIR_NAME) - if root == skills_dir_str and WISDOM_MANAGED_DIR_NAME in dirs and active_wisdom_org is None: - dirs.remove(WISDOM_MANAGED_DIR_NAME) elif root == org_root: dirs[:] = [d for d in dirs if d == active_org] - elif root == wisdom_root: - # Inside _wisdom/: descend ONLY into the last Gateway-verified org. - dirs[:] = [d for d in dirs if d == active_wisdom_org] - dirs[:] = [ - d - for d in dirs - if d not in EXCLUDED_SKILL_DIRS - and not (has_skill_md and d in SKILL_SUPPORT_DIRS) - ] + dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has_skill_md and d in SKILL_SUPPORT_DIRS)] if filename in files: matches.append(os.path.join(root, filename)) yield from map(Path, sorted(matches)) diff --git a/agent/turn_explainers.py b/agent/turn_explainers.py index 0ee38115e3..8d78279a31 100644 --- a/agent/turn_explainers.py +++ b/agent/turn_explainers.py @@ -139,10 +139,10 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "reported structural corruption (the transcript would " "have been lost on restart). Freeing disk space will " "not help. Recovery options:\n" - "1. Run `hermes doctor --fix`\n" + "1. Run `hermes {profile_arg}doctor --fix`\n" "2. Stop the gateway, then recover with:\n" - " hermes sessions recover --source {db_path} --inspect-only\n" - " (if it reports recoverable) hermes sessions recover " + " hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n" + " (if it reports recoverable) hermes {profile_arg}sessions recover " "--source {db_path} --output recovered-state.db\n" " — recovery snapshots the damaged file first; do NOT " "run `sqlite3 ... \".recover\"` against the live " @@ -151,6 +151,16 @@ _PERSISTENCE_CAUSE_EXPLANATIONS: Dict[str, str] = { "3. Restore from a backup in {backups_dir}/\n" "Then send your message again." ), + # SQLite scoped the corruption to the FTS index and the derived indexes could not be + # detached, so this write did not land; the message store itself is intact (#97794). + "fts_index": ( + "the turn was stopped because the session search index (FTS5) " + "is corrupt and could not be detached, so this message was not " + "saved. The message store itself is not damaged: do not run " + "recovery tools or restore a backup. Run `hermes {profile_arg}doctor --fix` " + "(or restart Hermes, which repairs the index on open), then " + "send your message again." + ), "disk": ( "the turn was stopped because session storage could not " "be written (the transcript would have been lost on " @@ -318,14 +328,14 @@ class TurnExplainersMixin: body = _PERSISTENCE_CAUSE_EXPLANATIONS.get( persistence_cause or "unknown", _PERSISTENCE_DEFAULT_EXPLANATION ) - if persistence_cause == "corrupt": - # Copy-pasteable, so name the store that actually failed: the agent's own - # SessionDB. A multi-profile backend (Desktop serve) hosts sessions whose - # state.db is NOT the process default, so the default would send the operator - # to inspect/repair the wrong profile's database (#105887). - from hermes_constants import get_default_hermes_root + if persistence_cause in ("corrupt", "fts_index"): + # Copy-pasteable, so name the store that actually failed and pin the profile: + # a multi-profile backend (Desktop serve) hosts sessions whose state.db is NOT + # the process default, and a bare `hermes` follows active_profile (#105887). + from hermes_constants import get_default_hermes_root, profile_cli_selector from hermes_state import _default_db_path + body = body.replace("{profile_arg}", profile_cli_selector()) body = body.replace("{db_path}", str(db_path or _default_db_path())) body = body.replace( "{backups_dir}", str(get_default_hermes_root() / "backups") diff --git a/agent/turn_facade.py b/agent/turn_facade.py index acd3bc97ed..5eb2b500d8 100644 --- a/agent/turn_facade.py +++ b/agent/turn_facade.py @@ -27,6 +27,7 @@ class TurnFacadeMixin: persist_user_display_metadata: Optional[Dict[str, Any]]=None, persist_user_platform_id: Optional[str]=None, moa_config: Optional[dict[str, Any]]=None, turn_author: Optional[Dict[str, Any]] = None, + relay_metadata: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """Forwarder — see ``agent.conversation_loop.run_conversation``.""" # A review shares this session_id for cache parity: fence review startup or interrupt @@ -94,8 +95,14 @@ class TurnFacadeMixin: parent_session_id=relay_parent_session_id, model=str(getattr(self, "model", None) or ""), ) + relay_turn_kwargs: Dict[str, Any] = { + "turn_id": relay_turn_id, + "task_id": effective_task_id, + } + if relay_metadata: + relay_turn_kwargs["metadata"] = relay_metadata relay_turn = relay_runtime.SESSION_COORDINATOR.begin_turn( - relay_lease, turn_id=relay_turn_id, task_id=effective_task_id + relay_lease, **relay_turn_kwargs ) # Minimal relay-runtime shims may lack the opt-out flag: default enabled. if getattr(relay_turn, "relay_enabled", True): diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index 41b6c053dc..fd61f8144c 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -1080,6 +1080,14 @@ def compute_error_backoff( agent._emit_status(_retry_status) else: agent._buffer_status(_retry_status) + # The buffered line only replays if every retry fails; the live status + # line is the one thing the user sees meanwhile. Name the wait there so a + # 60s backoff after a 5xx is not an anonymous spinner — this is transient + # (rewritten by the next frame, cleared on recovery), so it does not add + # the transcript chatter the buffer exists to avoid. + agent._emit_wait_notice( + f"⏳ waiting on provider — retrying in {wait_time:.0f}s (attempt {retry_count}/{max_retries})" + ) logger.warning( "Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s", wait_time, retry_count, max_retries, agent._client_log_context(), diff --git a/apps/bootstrap-installer/package.json b/apps/bootstrap-installer/package.json index e851e237c8..b7f82b4fa5 100644 --- a/apps/bootstrap-installer/package.json +++ b/apps/bootstrap-installer/package.json @@ -1,7 +1,7 @@ { "name": "@hermes/bootstrap-installer", "private": true, - "version": "0.0.1", + "version": "0.21.1", "description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.", "type": "module", "scripts": { diff --git a/apps/bootstrap-installer/src-tauri/Cargo.toml b/apps/bootstrap-installer/src-tauri/Cargo.toml index 78fc71e56c..2057af89bb 100644 --- a/apps/bootstrap-installer/src-tauri/Cargo.toml +++ b/apps/bootstrap-installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hermes-bootstrap" -version = "0.0.1" +version = "0.21.1" description = "Hermes Setup — signed installer that drives scripts/install.ps1" authors = ["Nous Research "] edition = "2021" diff --git a/apps/bootstrap-installer/src-tauri/tauri.conf.json b/apps/bootstrap-installer/src-tauri/tauri.conf.json index a74bd105c3..c789e9524c 100644 --- a/apps/bootstrap-installer/src-tauri/tauri.conf.json +++ b/apps/bootstrap-installer/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Hermes", - "version": "0.0.1", + "version": "0.21.1", "identifier": "com.nousresearch.hermes.setup", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 1c97a0d98d..efc590713e 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -221,9 +221,13 @@ Holding Cmd (Ctrl off macOS) reveals small slot numbers over the target strip's status dots after 400ms, without changing tab widths. Hints follow the same binding and hovered/focused-zone resolver as the number shortcuts. -Sticky user messages mask scrolling content with the opaque chat surface, -including the gap above them. Use `data-glass-opaque` so Glass cannot clear the -mask; no gradient or backdrop blur. +Tab close buttons fade the label with a content mask, not a painted gradient. +The tab reads its surface token directly so glass tint is painted only once. + +Sticky user messages clip covered scrolling content, including the gap above +them. Their wrappers stay unpainted; only the rounded user bubble owns a fill. +Clipping follows the pinned prompt and its live height without changing layout, +so glass and message-bubble transparency do not reveal scrolling text. ## Feedback & empty/error/loading states diff --git a/apps/desktop/e2e/connectors.spec.ts b/apps/desktop/e2e/connectors.spec.ts new file mode 100644 index 0000000000..1fb15fe8f5 --- /dev/null +++ b/apps/desktop/e2e/connectors.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from './test' +import { createSandbox, buildAppEnv, launchDesktop } from './fixtures' + +const GMAIL = 'Gmail' +const CALENDAR = 'Google Calendar' + +test.describe('flagged connector onboarding', () => { + test('shows the optional connector controls in a fresh flagged session', async () => { + const sandbox = createSandbox('connectors-ui') + const { app, page } = await launchDesktop(buildAppEnv(sandbox, { + HERMES_GUEST_ONBOARDING: '1' + })) + + try { + await expect(page.getByText('Connect your apps')).toBeVisible({ timeout: 90_000 }) + await expect(page.getByText(/Connecting is optional/i)).toBeVisible() + await expect(page.getByRole('button', { name: 'Connect', exact: true }).first()).toBeVisible() + await expect(page.getByRole('button', { name: 'Not now', exact: true }).first()).toBeVisible() + await expect(page.getByText(GMAIL)).toBeVisible() + await expect(page.getByText(CALENDAR)).toBeVisible() + } finally { + await app.close().catch(() => undefined) + sandbox.cleanup() + } + }) + + test('keeps the default surface free of connector authorization controls', async () => { + const sandbox = createSandbox('connectors-off') + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + try { + await expect(page.locator('[data-connector-offer]')).toHaveCount(0) + } finally { + await app.close().catch(() => undefined) + sandbox.cleanup() + } + }) +}) diff --git a/apps/desktop/e2e/glass-surfaces.spec.ts b/apps/desktop/e2e/glass-surfaces.spec.ts new file mode 100644 index 0000000000..e346e3f8bc --- /dev/null +++ b/apps/desktop/e2e/glass-surfaces.spec.ts @@ -0,0 +1,350 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +import { expect, test } from '@playwright/test' +import { createServer, type ViteDevServer } from 'vite' + +const desktop = path.resolve(import.meta.dirname, '..') +let server: ViteDevServer +let scratch: string +let url: string + +test.beforeAll(async () => { + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-glass-')) + // Match the existing component-browser fixtures without sharing Vite's cache. + Object.assign(globalThis, { __dirname: desktop }) + server = await createServer({ + root: desktop, + configFile: path.join(desktop, 'vite.config.ts'), + configLoader: 'runner', + cacheDir: path.join(scratch, 'node_modules/.vite'), + server: { host: '127.0.0.1', port: 0, strictPort: false }, + optimizeDeps: { entries: ['scripts/fixtures/glass-surfaces.html'] } + }) + await server.listen() + url = `${server.resolvedUrls!.local[0]}scripts/fixtures/glass-surfaces.html` +}) + +test.afterAll(async () => { + await server?.close() + + if (scratch) { + await fs.rm(scratch, { recursive: true, force: true }) + } +}) + +test('glass tabs fade the label beneath the close button without repainting the field', async ({ page }) => { + await page.goto(url) + const tab = page.getByTestId('active-tab') + const close = tab.getByRole('button', { name: 'Close', exact: true }) + await expect(tab).toBeVisible() + + for (const id of ['active-tab', 'idle-tab', 'fixed-tab']) { + expect(await page.getByTestId(id).evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + } + + const bounds = await tab.boundingBox() + await tab.hover() + await expect(close).toBeVisible() + expect(await close.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + + const mask = await tab.getByText('A long session title', { exact: false }).evaluate(el => { + const masks = [] + + for (let node: Element | null = el; node && !node.hasAttribute('data-testid'); node = node.parentElement) { + masks.push(getComputedStyle(node).maskImage) + } + + return masks.find(value => value !== 'none') + }) + + expect(mask).toContain('linear-gradient') + expect(await tab.boundingBox()).toEqual(bounds) + + // Paint a continuous marker through the real label slot to test the mask's + // pixels, not just the existence of a gradient. The close glyph is hidden so + // its paint cannot be mistaken for label bleed-through. + const sample = await tab.evaluate(el => { + const content = el.querySelector('.pane-tab-content')! + const button = el.querySelector('button[aria-label="Close"]')! + content.style.background = '#ff0000' + button.style.visibility = 'hidden' + const bounds = content.getBoundingClientRect() + const closeBounds = button.getBoundingClientRect() + + return { + y: Math.floor(bounds.top + 2), + solid: Math.floor(bounds.left + 2), + fade: Math.floor(closeBounds.left - 7), + covered: Math.floor(closeBounds.left + 3) + } + }) + + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + const pixels = await page.screenshot({ omitBackground: true, path: test.info().outputPath('masked-tab.png') }) + + const alpha = await page.evaluate( + async ({ image, points }) => { + const img = new Image() + img.src = image + await img.decode() + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const context = canvas.getContext('2d')! + context.drawImage(img, 0, 0) + + return [points.solid, points.fade, points.covered].map(x => context.getImageData(x, points.y, 1, 1).data[3]) + }, + { image: `data:image/png;base64,${pixels.toString('base64')}`, points: sample } + ) + + expect(alpha[0]).toBeGreaterThan(alpha[1]) + expect(alpha[1]).toBeGreaterThan(alpha[2]) + expect(alpha[2]).toBeLessThan(128) + await tab.evaluate(el => { + el.querySelector('.pane-tab-content')!.style.removeProperty('background') + el.querySelector('button[aria-label="Close"]')!.style.removeProperty('visibility') + }) + // The active underline remains on the tab itself, without a second stroke + // on the close button increasing its opacity. + expect(await tab.evaluate(el => getComputedStyle(el).boxShadow)).toContain('inset') + await close.click() + await expect(page.locator('body')).toHaveAttribute('data-closed', 'true') + await expect(page.locator('body')).not.toHaveAttribute('data-activated', 'true') + + for (const id of ['idle-tab', 'short-tab', 'selected-tab']) { + const sampleTab = page.getByTestId(id) + const before = await sampleTab.boundingBox() + await sampleTab.hover() + const content = sampleTab.locator('.pane-tab-content') + expect(await content.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient') + expect(await sampleTab.boundingBox()).toEqual(before) + expect( + await sampleTab + .getByRole('button', { name: 'Close', exact: true }) + .evaluate(el => getComputedStyle(el).backgroundColor) + ).toBe('rgba(0, 0, 0, 0)') + } + + // Glass scope and tint do not affect the fade. Solid mode still honors each + // tab's surface token instead of becoming unconditionally transparent. + await page.evaluate(() => { + document.documentElement.setAttribute('data-hermes-glass-scope', 'sidebar') + document.documentElement.style.setProperty('--translucency-glass-keep', '85%') + }) + expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.evaluate(() => document.documentElement.removeAttribute('data-hermes-glass')) + expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') + await tab.hover() + expect(await tab.locator('.pane-tab-content').evaluate(el => getComputedStyle(el).maskImage)).toContain( + 'linear-gradient' + ) +}) + +test('sticky prompts clip scrolling replies without an opaque backing', async ({ page }) => { + await page.goto(url) + const transcript = page.getByTestId('first-transcript') + const viewport = transcript.locator('[data-slot="aui_thread-viewport"]') + const prompt = transcript.locator('[data-slot="aui_user-message-root"]').first() + await expect(prompt).toBeAttached() + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(0) + expect(await prompt.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.evaluate(() => document.documentElement.style.setProperty('--user-bubble-keep', '0%')) + + await viewport.evaluate(el => { + el.scrollTop = 300 + }) + await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(300) + + const points = await prompt.evaluate(el => { + const rect = el.getBoundingClientRect() + const viewport = el.closest('[data-slot="aui_thread-viewport"]')!.getBoundingClientRect() + + return { + x: Math.floor(rect.left + rect.width / 2), + gap: Math.floor(viewport.top + 1), + hidden: Math.floor(rect.top + 8), + visible: Math.ceil(rect.bottom + 5) + } + }) + + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + const image = await page.screenshot({ omitBackground: true, path: test.info().outputPath('sticky-mask.png') }) + + const colors = await page.evaluate( + async ({ image, points }) => { + const bitmap = new Image() + bitmap.src = image + await bitmap.decode() + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d')! + context.drawImage(bitmap, 0, 0) + + return [points.gap, points.hidden, points.visible].map(y => + Array.from(context.getImageData(points.x, y, 1, 1).data) + ) + }, + { image: `data:image/png;base64,${image.toString('base64')}`, points } + ) + + expect(colors[0][3]).toBeLessThan(128) + expect(colors[1][3]).toBeLessThan(128) + expect(colors[2]).toEqual([255, 0, 0, 255]) + + const clipEdge = () => + transcript + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + const rect = el.getBoundingClientRect() + const clip = Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) + + const prompt = el + .closest('[data-slot="aui_message-group"]')! + .querySelector('[data-slot="aui_user-message-root"]')! + + return Math.abs(rect.top + clip - prompt.getBoundingClientRect().bottom) + }) + + await expect.poll(clipEdge).toBeLessThan(1) + + const clips = await page.evaluate(async () => { + const transcript = document.querySelector('[data-testid="first-transcript"]')! + const reply = transcript.querySelector('[data-slot="aui_assistant-message-root"]')! + const read = () => getComputedStyle(reply).clipPath + const before = read() + document.querySelector('[data-testid="first-transcript-append"]')!.click() + const samples = [read()] + + for (let i = 0; i < 3; i++) { + await new Promise(requestAnimationFrame) + samples.push(read()) + } + + return { before, samples } + }) + + expect(clips.before).not.toBe('none') + expect(clips.samples.every(value => value !== 'none')).toBe(true) + + // Expanding a pinned prompt must move the clip without needing a scroll. + await prompt.getByRole('button').click() + await expect.poll(() => prompt.getByRole('button').evaluate(el => el.getBoundingClientRect().height)).toBe(140) + await expect.poll(clipEdge).toBeLessThan(1) + + // A larger secondary-window titlebar offset moves both the sticky and clip. + await viewport.evaluate(el => el.style.setProperty('--sticky-human-top', '55px')) + await viewport.evaluate(el => { + el.scrollTop = 301 + }) + await expect.poll(clipEdge).toBeLessThan(1) + + // Scroll back through attachments and out of the pinned state. Nothing may + // remain clipped, and the other pane's independently pinned reply is intact. + await viewport.evaluate(el => el.style.removeProperty('--sticky-human-top')) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0) + await expect(transcript.getByTestId('attachment').first()).toBeVisible() + const other = page.getByTestId('second-transcript') + expect(await other.locator('[data-slot="aui_thread-viewport"]').evaluate(el => el.scrollTop)).toBeGreaterThan(0) + expect(await other.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0) + + // Enter another turn via a large jump (including virtualized history), then + // return: stale clip styles must leave the previous turn. + await viewport.evaluate(el => { + el.scrollTop = 1400 + }) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0) + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0) + + // A standalone reply can still occupy the gap below a secondary titlebar + // when the following prompt pins. It must be clipped across group boundaries. + await page.goto(`${url}?preceding`) + const gapViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]') + await gapViewport.locator('[data-slot="aui_user-message-root"]').first().waitFor({ state: 'attached' }) + await gapViewport.evaluate(el => { + el.scrollTop = 0 + }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const gapScroll = await gapViewport.evaluate(el => { + el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + el.style.setProperty('--sticky-human-top', '55px') + const group = el.querySelector('[data-slot="aui_user-message-root"]')!.closest('[data-slot="aui_message-group"]')! + const target = el.scrollTop + group.getBoundingClientRect().top - el.getBoundingClientRect().top - 45 + el.scrollTop = target + + return el.scrollTop + }) + + await expect.poll(() => gapViewport.evaluate(el => el.scrollTop)).toBe(gapScroll) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const gapClear = () => + gapViewport.evaluate(el => { + const previous = el.querySelector('[data-slot="aui_assistant-message-root"]')! + const bounds = el.getBoundingClientRect() + const x = bounds.left + bounds.width / 2 + const hit = document.elementFromPoint(x, bounds.top + 34) + + return { hidden: !previous.contains(hit), clip: getComputedStyle(previous).clipPath } + }) + + await expect.poll(async () => (await gapClear()).hidden).toBe(true) + expect((await gapClear()).clip).not.toBe('none') + + // Exercise the production message/edit components as well as the colored + // clipping markers. Editing replaces the prompt with a display:contents root. + await page.goto(`${url}?real`) + const realViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]') + await realViewport.locator('[data-slot="aui_assistant-message-root"]').first().waitFor() + await realViewport + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + el.style.height = '900px' + }) + await realViewport.evaluate(el => { + el.scrollTop = 300 + }) + const realPrompt = realViewport.locator('[data-slot="aui_user-message-root"]').first() + await realPrompt.getByRole('button', { name: 'Edit message', exact: true }).click() + const editor = realViewport.getByRole('textbox', { name: 'Edit message', exact: true }) + await expect(editor).toBeVisible() + await editor.fill('Expanded editable prompt\n'.repeat(8)) + await realViewport.evaluate(el => { + el.scrollTop = 400 + }) + await expect + .poll(() => + realViewport + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + const prompt = el + .closest('[data-slot="aui_message-group"]')! + .querySelector('[data-slot="aui_user-message-root"]')! + + return Math.abs( + el.getBoundingClientRect().top + + Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) - + prompt.getBoundingClientRect().bottom + ) + }) + ) + .toBeLessThan(1) +}) diff --git a/apps/desktop/electron/guest-onboarding-flag.test.ts b/apps/desktop/electron/guest-onboarding-flag.test.ts index 95954fbb9a..75108f69d9 100644 --- a/apps/desktop/electron/guest-onboarding-flag.test.ts +++ b/apps/desktop/electron/guest-onboarding-flag.test.ts @@ -2,9 +2,17 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' +import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding' import { buildSpawnCommand } from './remote-lifecycle' +test('skipIntroEnabled: exactly "1" in env or --skip-intro on argv skips the first-run film', () => { + assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: '1' }), true) + assert.equal(skipIntroEnabled(['electron', '.', '--skip-intro'], {}), true) + + assert.equal(skipIntroEnabled([], {}), false) + assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: 'true' }), false) +}) + test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => { assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true) assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true) diff --git a/apps/desktop/electron/guest-onboarding.ts b/apps/desktop/electron/guest-onboarding.ts index 0610dd5c47..ae399eb280 100644 --- a/apps/desktop/electron/guest-onboarding.ts +++ b/apps/desktop/electron/guest-onboarding.ts @@ -6,6 +6,11 @@ export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING' export const GUEST_ONBOARDING_FLAG = '--guest-onboarding' +// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal, +// so anyone iterating on the guided chat behind it otherwise sits through it +// on every fresh HERMES_HOME. Renderer-only; the backend never sees it. +export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO' +export const SKIP_INTRO_FLAG = '--skip-intro' export function guestOnboardingEnabled( argv: readonly string[] = process.argv, @@ -14,6 +19,10 @@ export function guestOnboardingEnabled( return env[GUEST_ONBOARDING_ENV] === '1' || argv.includes(GUEST_ONBOARDING_FLAG) } +export function skipIntroEnabled(argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): boolean { + return env[SKIP_INTRO_ENV] === '1' || argv.includes(SKIP_INTRO_FLAG) +} + // Outermost wrapper for a backend spawn env: the flag is written LAST so no // earlier spread (process.env, backend.env) can resurrect a stray value, and // "off" is an explicit '0' rather than an absent key so a '1' inherited from diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 069c62caee..ea8858f49c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -215,7 +215,7 @@ import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gatewa import { resolveGatewayVersion } from './gateway-version' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' -import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' +import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding' import { readAndConsumeHandoffResult } from './handoff-result' import { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, @@ -279,6 +279,8 @@ import { resolveOauthRestAuth, resolveReadinessProbeAuth } from './native-auth-decisions' +import { fetchLocalMedia } from './media-range' +import { createNativeAccessTokenCoordinator, NativeAuthChangedError } from './native-access-token' import { nativeRefreshUrl, type NativeTokenSet, @@ -816,6 +818,7 @@ const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1' // One launch decision must reach both the renderer and every backend spawn. const GUEST_ONBOARDING: boolean = guestOnboardingEnabled() +const SKIP_INTRO = skipIntroEnabled() const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -1269,13 +1272,10 @@ protocol.registerSchemesAsPrivileged([ function registerMediaProtocol() { const handler = createMediaProtocolHandler({ ensureRemoteBearer: baseUrl => ensureNativeAccessToken(baseUrl).catch(() => null), - fetchLocal: (resolvedPath, headers, method) => - electronNet.fetch(pathToFileURL(resolvedPath).toString(), { - bypassCustomProtocolHandlers: true, - credentials: 'omit', - headers, - method - }), + // Answer local files ourselves: Electron's file:// loader ignores Range and + // returns the whole body as 200 without Accept-Ranges, which makes