Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76fa55240d | ||
|
|
a09343cc96 | ||
|
|
f456f302df | ||
|
|
8972a151a4 | ||
|
|
a2d7f538d4 | ||
|
|
9c16ca8790 | ||
|
|
4717989c10 | ||
|
|
73dd584995 | ||
|
|
3edd09a46f | ||
|
|
875aa8f162 | ||
|
|
85503dceca | ||
|
|
955fa40062 | ||
|
|
0d3e2cc539 | ||
|
|
c94e93a648 | ||
|
|
39f40ece70 | ||
|
|
0edeee14c6 | ||
|
|
b4fbf7b93c | ||
|
|
9662b76d59 | ||
|
|
899acfe42f | ||
|
|
ed2b9e43c8 | ||
|
|
cedd9b6d47 | ||
|
|
dd40600e0a | ||
|
|
5e81113d09 | ||
|
|
04b3f19538 | ||
|
|
b8e2c16579 | ||
|
|
4829f8d2c5 | ||
|
|
cb2c13055e | ||
|
|
264ac72b67 | ||
|
|
f38f7a3870 | ||
|
|
2450fd7066 | ||
|
|
0b5b7ddfd2 | ||
|
|
fa7f24e898 | ||
|
|
13f1efdd15 | ||
|
|
4d22b82933 | ||
|
|
419c8a98a9 | ||
|
|
975edd4140 | ||
|
|
d7d281fa37 | ||
|
|
292192f7d7 | ||
|
|
c710868fbc | ||
|
|
3e74f75e41 | ||
|
|
fdc0d19566 | ||
|
|
65ddc7c4a1 | ||
|
|
3d14f01fd6 | ||
|
|
18d61bd06e | ||
|
|
4490c7cf8d | ||
|
|
e96ca1a0d3 |
@@ -0,0 +1,700 @@
|
||||
"""Coding-context awareness — base Hermes, every interactive surface.
|
||||
|
||||
When the user runs Hermes inside a code workspace (CLI, TUI, desktop app, or an
|
||||
editor over ACP), Hermes shifts into a **coding posture**. This module is the
|
||||
single place that decides whether we're in that posture and what it implies,
|
||||
so the rest of the codebase never re-derives "are we coding?" on its own.
|
||||
|
||||
Architecture — one seam, many consumers
|
||||
----------------------------------------
|
||||
The posture is modelled as a frozen :class:`RuntimeMode` selected from a small
|
||||
:class:`ContextProfile` registry (today: ``coding`` and ``general``). A profile
|
||||
is *data* — it declares the toolset to collapse to, the operating brief to
|
||||
inject, and hints for other domains (model routing, memory, subagents). Every
|
||||
domain reads the same resolved object instead of probing git/config itself:
|
||||
|
||||
* **System prompt** — ``RuntimeMode.system_blocks()`` → the operating brief +
|
||||
a live git/workspace snapshot (``agent/system_prompt.py``).
|
||||
* **Toolset** — ``RuntimeMode.toolset_selection()`` → the ``coding`` toolset
|
||||
plus the user's enabled MCP servers (``cli.py`` / ``tui_gateway``). Only
|
||||
under the opt-in ``focus`` mode: the default posture is prompt-only and
|
||||
never touches the user's configured toolsets (toolsets like messaging /
|
||||
smart-home / music are off-by-default anyway, and someone who explicitly
|
||||
enabled image-gen or Spotify shouldn't lose it for being in a git repo).
|
||||
* **Delegation** — subagents inherit the parent's toolset and run through the
|
||||
same prompt builder, so the coding posture propagates to children for free.
|
||||
* **Model / memory / compression** — declared on the profile
|
||||
(``model_hint``, ``memory_policy``) as the extension seam; consumers read
|
||||
``mode.profile`` rather than re-deciding.
|
||||
|
||||
Cache safety
|
||||
------------
|
||||
The mode is resolved **once** and is immutable. The workspace snapshot is built
|
||||
once at prompt-build time and baked into the *stable* system-prompt tier — never
|
||||
re-probed per turn (that would shatter the prompt cache). Branch and dirty state
|
||||
drift mid-session, so the brief tells the model to re-check with ``git`` before
|
||||
acting on the snapshot. A ``/coding`` flip therefore only takes effect next
|
||||
session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||
|
||||
Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets untouched.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
* ``off`` — disable entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("hermes.coding_context")
|
||||
|
||||
CODING_TOOLSET = "coding"
|
||||
|
||||
# Surfaces where a coding posture makes sense under ``auto``. Messaging
|
||||
# platforms (telegram, discord, slack, …) are intentionally absent — a chat bot
|
||||
# in a group is not pair-programming.
|
||||
INTERACTIVE_CODING_PLATFORMS = {"cli", "tui", "acp", "desktop", ""}
|
||||
|
||||
# Project-root signals that mark a directory as a code workspace even when it
|
||||
# isn't (yet) a git repo. Cheap filename checks — no parsing.
|
||||
_PROJECT_MARKERS = (
|
||||
"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt",
|
||||
"package.json", "tsconfig.json", "deno.json",
|
||||
"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Gemfile", "composer.json", "mix.exs", "pubspec.yaml",
|
||||
"CMakeLists.txt", "Makefile", "Dockerfile",
|
||||
"AGENTS.md", "CLAUDE.md", ".cursorrules",
|
||||
)
|
||||
|
||||
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||
|
||||
# Lockfile → package manager, checked in priority order.
|
||||
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||
_JS_LOCKFILES = (
|
||||
("pnpm-lock.yaml", "pnpm"), ("bun.lockb", "bun"), ("bun.lock", "bun"),
|
||||
("yarn.lock", "yarn"), ("package-lock.json", "npm"),
|
||||
)
|
||||
|
||||
# package.json scripts / Makefile targets worth surfacing as verify commands.
|
||||
_VERIFY_TARGETS = ("test", "tests", "lint", "typecheck", "check", "build", "fmt", "format")
|
||||
_MAX_VERIFY_COMMANDS = 8
|
||||
_MAX_FACT_FILE_BYTES = 256 * 1024
|
||||
|
||||
_GIT_TIMEOUT = 2.5
|
||||
|
||||
|
||||
# Per-model edit-format steering. Matching the edit tool format to how a model
|
||||
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
|
||||
# patch-style diffs best; Anthropic models — and most open-weight coding
|
||||
# models, whose RL scaffolds use str_replace-style editors — do best with
|
||||
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
"patch": (
|
||||
("gpt", "codex"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
|
||||
"for structured or multi-file changes — it's the diff format you handle "
|
||||
"most reliably. Use `mode='replace'` for a single small swap.",
|
||||
),
|
||||
"replace": (
|
||||
("claude", "sonnet", "opus", "haiku",
|
||||
"gemini", "gemma", "deepseek", "qwen", "kimi", "glm", "grok",
|
||||
"hermes", "llama", "mistral", "devstral", "minimax"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` in `mode='replace'` — match a unique "
|
||||
"snippet and swap it. Reach for `mode='patch'` (V4A) only when an edit "
|
||||
"genuinely spans several files at once.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _model_family(model: Optional[str]) -> Optional[str]:
|
||||
"""Classify a model id into an edit-format family key, or ``None``.
|
||||
|
||||
Used to steer the coding posture toward the edit tool format a model was
|
||||
trained on. Family-agnostic by design: an unrecognised model gets ``None``
|
||||
and the operating brief's neutral edit wording applies.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
for family, (needles, _line) in _EDIT_FORMAT_GUIDANCE.items():
|
||||
if any(n in lowered for n in needles):
|
||||
return family
|
||||
return None
|
||||
|
||||
|
||||
def _edit_format_line(model: Optional[str]) -> str:
|
||||
"""The edit-format guidance line for this model's family (``""`` if none)."""
|
||||
family = _model_family(model)
|
||||
if family is None:
|
||||
return ""
|
||||
return _EDIT_FORMAT_GUIDANCE[family][1]
|
||||
|
||||
|
||||
# Operating brief for the coding posture. Tool names referenced here (read_file,
|
||||
# search_files, patch, write_file, terminal, todo) are in the coding toolset and
|
||||
# in _HERMES_CORE_TOOLS, so they're present on every surface this fires on.
|
||||
CODING_AGENT_GUIDANCE = (
|
||||
"You are a coding agent pairing with the user inside their codebase. "
|
||||
"Operate like a careful senior engineer.\n"
|
||||
"\n"
|
||||
"Gather context first:\n"
|
||||
"- Read the relevant files with `read_file` and locate code with "
|
||||
"`search_files` before changing anything. Trace a symbol to its definition "
|
||||
"and usages rather than guessing its shape.\n"
|
||||
"- Batch independent lookups: when several reads/searches don't depend on "
|
||||
"each other, issue them together in one turn instead of one at a time.\n"
|
||||
"- Never invent files, symbols, APIs, or imports. If you haven't seen it in "
|
||||
"the repo, go look. Don't assume a library is available — check the project "
|
||||
"manifest (pyproject.toml / package.json / Cargo.toml / go.mod) and how "
|
||||
"neighbouring files import it.\n"
|
||||
"\n"
|
||||
"Make changes through the tools, not the chat:\n"
|
||||
"- Edit with `patch`/`write_file`. Do NOT print code blocks to the user as "
|
||||
"a substitute for editing — apply the change, then summarise it. Only show "
|
||||
"code when the user explicitly asks to see it.\n"
|
||||
"- Match the project's existing style and conventions; AGENTS.md / "
|
||||
"CLAUDE.md / .cursorrules already in context win over your defaults. Touch "
|
||||
"only what the task needs — no drive-by refactors, renames, or reformatting "
|
||||
"— and add any imports/dependencies your code requires.\n"
|
||||
"- If an edit fails to apply, re-read the file to get the current exact "
|
||||
"contents before retrying — don't repeat a stale patch. If the same region "
|
||||
"fails twice, rewrite the enclosing function or file with `write_file` "
|
||||
"instead of attempting a third patch.\n"
|
||||
"\n"
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
"attempts on the same file and ask the user rather than looping.\n"
|
||||
"- Track multi-step work with `todo`. Reference code as `path:line` instead "
|
||||
"of pasting whole files.\n"
|
||||
"\n"
|
||||
"Respect the user's repo: don't commit, push, or rewrite history unless "
|
||||
"asked, and never read, print, or commit secrets — leave `.env` and "
|
||||
"credential files alone unless the user explicitly asks. The Workspace "
|
||||
"block below is a snapshot from session start — re-run `git status`/"
|
||||
"`git branch` before relying on it. Be concise: lead with the change or "
|
||||
"answer, not a preamble."
|
||||
)
|
||||
|
||||
|
||||
# ── Context profiles (declarative posture definitions) ──────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextProfile:
|
||||
"""A named operating posture. Pure data — consumers read these fields.
|
||||
|
||||
``toolset`` — collapse to this toolset (+ enabled MCP) when no explicit
|
||||
selection is pinned; ``None`` keeps the platform default.
|
||||
``guidance`` — operating brief injected into the stable system prompt;
|
||||
``""`` injects nothing.
|
||||
``model_hint`` — routing preference key for smart model routing
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``hidden_skill_categories`` — skill categories pruned from the system-prompt
|
||||
skill index while this posture is active. Discovery-only:
|
||||
nothing is disabled — ``skills_list`` still returns the
|
||||
full catalog and ``skill_view`` loads anything. Deny-list
|
||||
semantics so unknown/custom categories stay visible.
|
||||
"""
|
||||
|
||||
name: str
|
||||
toolset: Optional[str] = None
|
||||
guidance: str = ""
|
||||
model_hint: Optional[str] = None
|
||||
memory_policy: str = "default"
|
||||
hidden_skill_categories: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Hidden from
|
||||
# the prompt's skill index in the coding posture (deny-list — anything not
|
||||
# listed here, incl. custom user categories, stays visible). Coding-adjacent
|
||||
# categories (devops, github, mcp, data-science, diagramming, research,
|
||||
# security, …) are intentionally absent.
|
||||
_NON_CODING_SKILL_CATEGORIES = (
|
||||
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||
"productivity", "shopping", "smart-home", "social-media", "travel",
|
||||
"yuanbao",
|
||||
)
|
||||
|
||||
|
||||
GENERAL_PROFILE = ContextProfile(name="general")
|
||||
CODING_PROFILE = ContextProfile(
|
||||
name="coding",
|
||||
toolset=CODING_TOOLSET,
|
||||
guidance=CODING_AGENT_GUIDANCE,
|
||||
model_hint="coding",
|
||||
memory_policy="project",
|
||||
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
)
|
||||
|
||||
_PROFILES: dict[str, ContextProfile] = {
|
||||
GENERAL_PROFILE.name: GENERAL_PROFILE,
|
||||
CODING_PROFILE.name: CODING_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def get_profile(name: str) -> ContextProfile:
|
||||
"""Return a registered profile, falling back to ``general``."""
|
||||
return _PROFILES.get(name, GENERAL_PROFILE)
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||
"""Return the normalized ``agent.coding_context`` mode (auto/focus/on/off)."""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto")
|
||||
mode = str(raw).strip().lower()
|
||||
if mode in {"focus", "strict", "lean"}:
|
||||
return "focus"
|
||||
if mode in {"on", "true", "yes", "1", "always"}:
|
||||
return "on"
|
||||
if mode in {"off", "false", "no", "0", "never"}:
|
||||
return "off"
|
||||
return "auto"
|
||||
|
||||
|
||||
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
|
||||
if cwd:
|
||||
return Path(cwd).expanduser()
|
||||
try:
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
|
||||
return resolve_agent_cwd()
|
||||
except Exception:
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def _git_root(cwd: Path) -> Optional[Path]:
|
||||
current = cwd.resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / ".git").exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _home() -> Optional[Path]:
|
||||
try:
|
||||
return Path.home().resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def _marker_root(cwd: Path) -> Optional[Path]:
|
||||
"""Nearest ancestor that looks like a project root, or ``None``.
|
||||
|
||||
Walks up at most a few levels so a manifest in the workspace root counts
|
||||
even when the user is in a subdirectory. ``$HOME`` itself is skipped — a
|
||||
Makefile or AGENTS.md sitting in the home directory is global user config,
|
||||
not a project-root signal.
|
||||
"""
|
||||
current = cwd.resolve()
|
||||
home = _home()
|
||||
for depth, parent in enumerate([current, *current.parents]):
|
||||
if depth > 6:
|
||||
break
|
||||
if parent == home:
|
||||
continue
|
||||
for marker in _PROJECT_MARKERS:
|
||||
if (parent / marker).exists():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||
"""Resolve which profile applies.
|
||||
|
||||
``auto``/``focus``: coding when the surface is interactive AND the cwd is a
|
||||
code workspace (a git repo or a recognised project root). ``on``: always
|
||||
coding. ``off``: always general.
|
||||
|
||||
A git repo rooted at ``$HOME`` (the dotfiles pattern) is NOT a workspace
|
||||
signal — without the guard, every session anywhere under a dotfiles-managed
|
||||
home directory would silently flip to the coding posture.
|
||||
|
||||
Detection is intentionally not memoized: it's a handful of ``stat`` calls,
|
||||
and callers resolve the mode once per session anyway. Caching here would
|
||||
risk a stale posture if a long-lived process (gateway/TUI) serves sessions
|
||||
from different working directories.
|
||||
"""
|
||||
if mode == "off":
|
||||
return GENERAL_PROFILE.name
|
||||
if mode == "on":
|
||||
return CODING_PROFILE.name
|
||||
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||
return GENERAL_PROFILE.name
|
||||
cwd = Path(cwd_str)
|
||||
git_root = _git_root(cwd)
|
||||
if git_root is not None and git_root == _home():
|
||||
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||
if git_root is not None or _marker_root(cwd) is not None:
|
||||
return CODING_PROFILE.name
|
||||
return GENERAL_PROFILE.name
|
||||
|
||||
|
||||
# ── RuntimeMode (the seam) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeMode:
|
||||
"""The resolved operating posture for a session. Immutable by construction.
|
||||
|
||||
Built once via :func:`resolve_runtime_mode` and consumed by every domain
|
||||
that cares about the coding/general distinction. Never mutate or re-resolve
|
||||
mid-session — that would break the prompt cache.
|
||||
"""
|
||||
|
||||
profile: ContextProfile
|
||||
surface: str
|
||||
cwd: Path
|
||||
# The normalized ``agent.coding_context`` mode this posture was resolved
|
||||
# under (auto/focus/on/off). Toolset collapse is gated on ``focus``.
|
||||
config_mode: str = "auto"
|
||||
# The model id this session runs (e.g. "anthropic/claude-opus-4.8"). Used
|
||||
# only to steer edit-format guidance toward the model's family — see
|
||||
# ``_edit_format_line``. Fixed for the session, so cache-safe.
|
||||
model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
return self.profile.name
|
||||
|
||||
@property
|
||||
def is_coding(self) -> bool:
|
||||
return self.profile.name == CODING_PROFILE.name
|
||||
|
||||
def toolset_selection(self, config: Optional[dict[str, Any]] = None) -> Optional[list[str]]:
|
||||
"""Toolset list for this posture, or ``None`` to keep the platform default.
|
||||
|
||||
Non-``None`` only under the opt-in ``focus`` mode. The default posture
|
||||
is prompt-only: most strippable toolsets are off-by-default anyway, and
|
||||
a user who explicitly enabled one (image-gen for frontend/game assets,
|
||||
messaging for build notifications, …) keeps it while coding.
|
||||
|
||||
Callers apply this only when the user hasn't pinned an explicit
|
||||
selection (``--toolsets``, ``HERMES_TUI_TOOLSETS``, …); they never
|
||||
override a pin. Returns the profile's toolset plus enabled MCP servers.
|
||||
"""
|
||||
if self.config_mode != "focus":
|
||||
return None
|
||||
if self.profile.toolset is None:
|
||||
return None
|
||||
return [self.profile.toolset, *_enabled_mcp_servers(config)]
|
||||
|
||||
def system_blocks(self) -> list[str]:
|
||||
"""Stable system-prompt blocks for this posture (brief + workspace).
|
||||
|
||||
The operating brief carries a model-family edit-format nudge appended
|
||||
to it (one cached string, not a separate block) so the model is steered
|
||||
toward the `patch` mode it handles best — see ``_edit_format_line``.
|
||||
"""
|
||||
if not self.is_coding:
|
||||
return []
|
||||
blocks: list[str] = []
|
||||
if self.profile.guidance:
|
||||
brief = self.profile.guidance
|
||||
edit_line = _edit_format_line(self.model)
|
||||
if edit_line:
|
||||
brief = f"{brief}\n{edit_line}"
|
||||
blocks.append(brief)
|
||||
workspace = build_coding_workspace_block(self.cwd)
|
||||
if workspace:
|
||||
blocks.append(workspace)
|
||||
return blocks
|
||||
|
||||
def hidden_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to prune from the prompt's skill index (may be empty)."""
|
||||
return frozenset(self.profile.hidden_skill_categories)
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> RuntimeMode:
|
||||
"""Resolve the operating posture once. Cheap — a handful of ``stat`` calls.
|
||||
|
||||
This is the single entry point every domain should call. The returned
|
||||
object is immutable and safe to cache for the session. Detection itself is
|
||||
intentionally *not* memoized (see ``_detect_profile_name``) so a long-lived
|
||||
process can't pin a stale posture; callers resolve once per session and
|
||||
hold the result. ``model`` is recorded only to steer edit-format guidance;
|
||||
it never affects detection.
|
||||
"""
|
||||
resolved_cwd = _resolve_cwd(cwd)
|
||||
mode = _coding_mode(config)
|
||||
name = _detect_profile_name(
|
||||
mode, (platform or "").strip().lower(), str(resolved_cwd)
|
||||
)
|
||||
return RuntimeMode(
|
||||
profile=get_profile(name),
|
||||
surface=platform or "",
|
||||
cwd=resolved_cwd,
|
||||
config_mode=mode,
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
# ── Back-compat surface (thin wrappers over RuntimeMode) ────────────────────
|
||||
|
||||
|
||||
def is_coding_context(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether Hermes should operate in its coding posture right now."""
|
||||
return resolve_runtime_mode(platform=platform, cwd=cwd, config=config).is_coding
|
||||
|
||||
|
||||
def coding_selection(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> Optional[list[str]]:
|
||||
"""Toolset selection for the coding posture.
|
||||
|
||||
``None`` unless the user opted into ``focus`` mode AND the posture is
|
||||
active — the default coding posture never overrides configured toolsets.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).toolset_selection(config)
|
||||
|
||||
|
||||
def coding_system_blocks(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
"""Stable system-prompt blocks for the current posture (empty when general).
|
||||
|
||||
``model`` steers the brief's edit-format nudge toward the model's family.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config, model=model
|
||||
).system_blocks()
|
||||
|
||||
|
||||
def coding_hidden_skill_categories(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture prunes from the prompt's skill index.
|
||||
|
||||
Empty outside the coding posture. Discovery-only: hidden skills remain
|
||||
loadable via ``skills_list`` / ``skill_view``.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).hidden_skill_categories()
|
||||
|
||||
|
||||
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
"""Names of MCP servers the user has enabled — kept in the coding posture.
|
||||
|
||||
MCP servers (figma, browser, tophat, …) are explicitly configured and part
|
||||
of the coding workflow, not noise to strip.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
from hermes_cli.tools_config import _parse_enabled_flag
|
||||
|
||||
servers = read_raw_config().get("mcp_servers") or {}
|
||||
return [
|
||||
str(name)
|
||||
for name, cfg in servers.items()
|
||||
if isinstance(cfg, dict)
|
||||
and _parse_enabled_flag(cfg.get("enabled", True), default=True)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> str:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(cwd), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return out.stdout.strip() if out.returncode == 0 else ""
|
||||
|
||||
|
||||
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:
|
||||
"""Parse ``git status --porcelain=2 --branch`` into branch + counts."""
|
||||
branch: dict[str, str] = {}
|
||||
counts = {"staged": 0, "modified": 0, "untracked": 0, "conflicts": 0}
|
||||
for line in porcelain.splitlines():
|
||||
if line.startswith("# branch.head"):
|
||||
branch["head"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.upstream"):
|
||||
branch["upstream"] = line.split(maxsplit=2)[-1]
|
||||
elif line.startswith("# branch.ab"):
|
||||
parts = line.split()
|
||||
branch["ahead"], branch["behind"] = parts[2].lstrip("+"), parts[3].lstrip("-")
|
||||
elif line.startswith(("1 ", "2 ")):
|
||||
xy = line.split(maxsplit=2)[1]
|
||||
if xy[0] != ".":
|
||||
counts["staged"] += 1
|
||||
if xy[1] != ".":
|
||||
counts["modified"] += 1
|
||||
elif line.startswith("u "):
|
||||
counts["conflicts"] += 1
|
||||
elif line.startswith("? "):
|
||||
counts["untracked"] += 1
|
||||
return branch, counts
|
||||
|
||||
|
||||
def _read_small(path: Path) -> str:
|
||||
"""Read a small text file, or ``""`` — never raises, never reads huge files."""
|
||||
try:
|
||||
if not path.is_file() or path.stat().st_size > _MAX_FACT_FILE_BYTES:
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _project_facts(root: Path) -> list[str]:
|
||||
"""Detected project facts for the workspace snapshot.
|
||||
|
||||
The point is to hand the model its *verify loop* up front — which manifest,
|
||||
which package manager, and the exact test/lint/build commands — instead of
|
||||
making it rediscover them every session. Cheap: stat calls plus reads of a
|
||||
couple of small files; built once at prompt-build time (cache-safe).
|
||||
"""
|
||||
facts: list[str] = []
|
||||
|
||||
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
|
||||
package_managers = [
|
||||
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
|
||||
]
|
||||
if manifests:
|
||||
line = f"- Project: {', '.join(manifests[:6])}"
|
||||
if package_managers:
|
||||
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
|
||||
facts.append(line)
|
||||
|
||||
verify: list[str] = []
|
||||
if (root / "scripts" / "run_tests.sh").is_file():
|
||||
verify.append("scripts/run_tests.sh")
|
||||
if (root / "package.json").is_file():
|
||||
try:
|
||||
scripts = json.loads(_read_small(root / "package.json") or "{}").get("scripts") or {}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
scripts = {}
|
||||
js_pm = next((pm for lock, pm in _JS_LOCKFILES if (root / lock).is_file()), "npm")
|
||||
verify.extend(f"{js_pm} run {name}" for name in _VERIFY_TARGETS if name in scripts)
|
||||
if (root / "pytest.ini").is_file() or "[tool.pytest" in _read_small(root / "pyproject.toml"):
|
||||
verify.append("pytest")
|
||||
makefile = _read_small(root / "Makefile")
|
||||
if makefile:
|
||||
verify.extend(
|
||||
f"make {name}" for name in _VERIFY_TARGETS
|
||||
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
|
||||
)
|
||||
if verify:
|
||||
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
|
||||
facts.append(f"- Verify: {'; '.join(deduped)}")
|
||||
|
||||
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
|
||||
if context_files:
|
||||
facts.append(f"- Context files: {', '.join(context_files)}")
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
"""Workspace snapshot for the system prompt (empty outside a workspace).
|
||||
|
||||
Git state (branch/status/commits) when the cwd is in a repo, plus detected
|
||||
project facts (manifest, package manager, verify commands, context files)
|
||||
— so marker-only (non-git) projects still get a snapshot.
|
||||
"""
|
||||
resolved = _resolve_cwd(cwd)
|
||||
git_root = _git_root(resolved)
|
||||
root = git_root or _marker_root(resolved)
|
||||
if root is None:
|
||||
return ""
|
||||
|
||||
lines = ["Workspace (snapshot at session start — re-check with `git` before acting on it):"]
|
||||
lines.append(f"- Root: {root}")
|
||||
|
||||
if git_root is not None:
|
||||
branch, counts = _parse_status(_git(root, "status", "--porcelain=2", "--branch"))
|
||||
head = branch.get("head", "")
|
||||
if head and head != "(detached)":
|
||||
line = f"- Branch: {head}"
|
||||
if branch.get("upstream"):
|
||||
line += f" \u2192 {branch['upstream']}"
|
||||
ahead, behind = branch.get("ahead", "0"), branch.get("behind", "0")
|
||||
if ahead != "0" or behind != "0":
|
||||
line += f" (ahead {ahead}, behind {behind})"
|
||||
lines.append(line)
|
||||
elif head == "(detached)":
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
("untracked", counts["untracked"]), ("conflicts", counts["conflicts"]),
|
||||
) if n]
|
||||
lines.append(f"- Status: {', '.join(dirty) if dirty else 'clean'}")
|
||||
|
||||
recent = _git(root, "log", "-3", "--pretty=%h %s")
|
||||
if recent:
|
||||
lines.append("- Recent commits:")
|
||||
lines.extend(f" {c}" for c in recent.splitlines())
|
||||
|
||||
lines.extend(_project_facts(root))
|
||||
return "\n".join(lines)
|
||||
+30
-1
@@ -1101,11 +1101,12 @@ def _skill_should_show(
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
hidden_categories: "frozenset[str] | None" = None,
|
||||
) -> str:
|
||||
"""Build a compact skill index for the system prompt.
|
||||
|
||||
Two-layer cache:
|
||||
1. In-process LRU dict keyed by (skills_dir, tools, toolsets)
|
||||
1. In-process LRU dict keyed by (skills_dir, tools, toolsets, hidden)
|
||||
2. Disk snapshot (``.skills_prompt_snapshot.json``) validated by
|
||||
mtime/size manifest — survives process restarts
|
||||
|
||||
@@ -1115,6 +1116,12 @@ def build_skills_system_prompt(
|
||||
scanned alongside the local ``~/.hermes/skills/`` directory. External dirs
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
|
||||
``hidden_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) prunes whole categories from the rendered index.
|
||||
Discovery-only: the snapshot stores everything, ``skills_list`` /
|
||||
``skill_view`` still reach every skill, and a footer note tells the model
|
||||
the full catalog exists.
|
||||
"""
|
||||
skills_dir = get_skills_dir()
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
@@ -1139,6 +1146,7 @@ def build_skills_system_prompt(
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
_platform_hint,
|
||||
tuple(sorted(disabled)),
|
||||
tuple(sorted(hidden_categories or ())),
|
||||
)
|
||||
with _SKILLS_PROMPT_CACHE_LOCK:
|
||||
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
||||
@@ -1272,6 +1280,26 @@ def build_skills_system_prompt(
|
||||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
# Posture-driven category pruning (e.g. non-coding skills while pairing on
|
||||
# code). Match on the top-level category segment so nested categories
|
||||
# ("social-media/twitter") are pruned with their parent.
|
||||
hidden_note = ""
|
||||
if hidden_categories:
|
||||
before = sum(len(v) for v in skills_by_category.values())
|
||||
skills_by_category = {
|
||||
cat: entries
|
||||
for cat, entries in skills_by_category.items()
|
||||
if cat.split("/", 1)[0] not in hidden_categories
|
||||
}
|
||||
pruned = before - sum(len(v) for v in skills_by_category.values())
|
||||
if pruned:
|
||||
hidden_note = (
|
||||
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
|
||||
"current coding context are not listed here. The full catalog "
|
||||
"is available via skills_list if the user asks for something "
|
||||
"outside this list.)"
|
||||
)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
@@ -1320,6 +1348,7 @@ def build_skills_system_prompt(
|
||||
"</available_skills>\n"
|
||||
"\n"
|
||||
"Only proceed without loading a skill if genuinely none are relevant to the task."
|
||||
+ hidden_note
|
||||
)
|
||||
|
||||
# ── Store in LRU cache ────────────────────────────────────────────
|
||||
|
||||
@@ -191,9 +191,21 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
)
|
||||
if toolset
|
||||
}
|
||||
# Coding posture prunes non-coding skill categories from the index
|
||||
# (discovery-only — skills_list/skill_view still reach everything).
|
||||
_hidden_cats = frozenset()
|
||||
try:
|
||||
from agent.coding_context import coding_hidden_skill_categories
|
||||
|
||||
_hidden_cats = coding_hidden_skill_categories(
|
||||
platform=agent.platform, cwd=resolve_context_cwd()
|
||||
)
|
||||
except Exception:
|
||||
_hidden_cats = frozenset()
|
||||
skills_prompt = _r.build_skills_system_prompt(
|
||||
available_tools=agent.valid_tool_names,
|
||||
available_toolsets=avail_toolsets,
|
||||
hidden_categories=_hidden_cats or None,
|
||||
)
|
||||
else:
|
||||
skills_prompt = ""
|
||||
@@ -221,6 +233,26 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
if _env_hints:
|
||||
stable_parts.append(_env_hints)
|
||||
|
||||
# Coding posture (base Hermes, any interactive coding surface in a code
|
||||
# workspace — see agent/coding_context.py). The operating brief + the live
|
||||
# git/workspace snapshot are built once here and cached for the session;
|
||||
# the snapshot is never re-probed per turn (that would break the prompt
|
||||
# cache), so the brief tells the model to re-check git before relying on it.
|
||||
if agent.valid_tool_names:
|
||||
try:
|
||||
from agent.coding_context import coding_system_blocks
|
||||
|
||||
stable_parts.extend(
|
||||
coding_system_blocks(
|
||||
platform=agent.platform,
|
||||
cwd=resolve_context_cwd(),
|
||||
model=agent.model,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Coding-context probing must never block prompt build.
|
||||
pass
|
||||
|
||||
# Local Python toolchain probe — names python/pip/uv/PEP-668 state when
|
||||
# something is non-default so the model can pick the right install
|
||||
# strategy without discovering by failure. Emits a single line; emits
|
||||
|
||||
@@ -417,7 +417,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
|
||||
# ── Logging / callbacks ──────────────────────────────────────────
|
||||
tool_names_str = ", ".join(name for _, name, _, _, _, _ in parsed_calls)
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
print(f" ⚡ Concurrent: {num_tools} tool calls — {tool_names_str}")
|
||||
for i, (tc, name, args, middleware_trace, block_result, blocked_by_guardrail) in enumerate(parsed_calls, 1):
|
||||
args_str = json.dumps(args, ensure_ascii=False)
|
||||
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||
agent._safe_print(f" {cute_msg}")
|
||||
elif getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
elif not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
_preview_str = _multimodal_text_summary(function_result)
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||
@@ -866,7 +866,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
elif function_name == "skill_manage":
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
args_str = json.dumps(function_args, ensure_ascii=False)
|
||||
if agent.verbose_logging:
|
||||
print(f" 📞 Tool {i}: {function_name}({list(function_args.keys())})")
|
||||
@@ -1384,7 +1384,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# entire batch. The model sees it on the next API iteration.
|
||||
agent._apply_pending_steer_to_tool_results(messages, 1)
|
||||
|
||||
if not agent.quiet_mode:
|
||||
if not agent.quiet_mode and getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i} completed in {tool_duration:.2f}s")
|
||||
print(agent._wrap_verbose("Result: ", function_result))
|
||||
|
||||
@@ -31,6 +31,10 @@ const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
@@ -1312,6 +1316,11 @@ function runGit(args, options = {}) {
|
||||
|
||||
const firstLine = text => (text || '').split('\n').find(Boolean) || ''
|
||||
|
||||
async function getOriginUrl(updateRoot) {
|
||||
const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot })
|
||||
return origin.code === 0 ? origin.stdout.trim() : ''
|
||||
}
|
||||
|
||||
function emitUpdateProgress(payload) {
|
||||
const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() }
|
||||
rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`)
|
||||
@@ -1331,7 +1340,9 @@ async function resolveHealedBranch(updateRoot, branch) {
|
||||
return branch || 'main'
|
||||
}
|
||||
|
||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
|
||||
const originUrl = await getOriginUrl(updateRoot)
|
||||
const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin'
|
||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot })
|
||||
if (probe.code !== 2) {
|
||||
return branch
|
||||
}
|
||||
@@ -1359,6 +1370,40 @@ async function checkUpdates() {
|
||||
}
|
||||
|
||||
branch = await resolveHealedBranch(updateRoot, branch)
|
||||
const originUrl = await getOriginUrl(updateRoot)
|
||||
if (isOfficialSshRemote(originUrl)) {
|
||||
const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim())
|
||||
const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([
|
||||
git(['rev-parse', 'HEAD']),
|
||||
runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }),
|
||||
git(['status', '--porcelain']),
|
||||
git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
||||
])
|
||||
const targetSha = firstLine(target.stdout).split(/\s+/)[0] || ''
|
||||
if (target.code !== 0 || !targetSha) {
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
error: 'fetch-failed',
|
||||
message: firstLine(target.stderr) || 'git ls-remote failed.',
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
return {
|
||||
supported: true,
|
||||
branch,
|
||||
currentBranch,
|
||||
behind: currentSha && currentSha === targetSha ? 0 : 1,
|
||||
currentSha,
|
||||
targetSha,
|
||||
commits: [],
|
||||
dirty: dirtyStr.length > 0,
|
||||
hermesRoot: updateRoot,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||||
if (fetched.code !== 0) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Pure helpers for choosing a remote URL during passive update checks.
|
||||
*
|
||||
* A public install can end up with `origin=git@github.com:NousResearch/hermes-agent.git`.
|
||||
* If the user's GitHub SSH key is FIDO2/passkey-backed, a background `git fetch
|
||||
* origin` triggers an unexplained hardware-touch prompt. For passive checks
|
||||
* against the official repo we substitute the public HTTPS `ls-remote` path,
|
||||
* which needs no auth and cannot prompt. Active update/apply flows are left
|
||||
* unchanged.
|
||||
*
|
||||
* Extracted from main.cjs so the security-critical remote detection is unit
|
||||
* testable without booting Electron (main.cjs requires('electron') at load).
|
||||
*/
|
||||
|
||||
const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git'
|
||||
const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent'
|
||||
|
||||
// Normalize common GitHub remote URL forms to `host/owner/repo` (lowercased,
|
||||
// no trailing slash, no .git suffix) so SSH and HTTPS forms of the same repo
|
||||
// compare equal.
|
||||
function canonicalGitHubRemote(url) {
|
||||
if (!url) return ''
|
||||
let value = String(url).trim()
|
||||
if (value.startsWith('git@github.com:')) {
|
||||
value = `github.com/${value.slice('git@github.com:'.length)}`
|
||||
} else if (value.startsWith('ssh://git@github.com/')) {
|
||||
value = `github.com/${value.slice('ssh://git@github.com/'.length)}`
|
||||
} else {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}`
|
||||
} catch {
|
||||
// Leave non-URL forms unchanged.
|
||||
}
|
||||
}
|
||||
value = value.trim().replace(/\/+$/, '')
|
||||
if (value.endsWith('.git')) value = value.slice(0, -4)
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
function isSshRemote(url) {
|
||||
const value = String(url || '').trim().toLowerCase()
|
||||
return value.startsWith('git@') || value.startsWith('ssh://')
|
||||
}
|
||||
|
||||
function isOfficialSshRemote(url) {
|
||||
return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Tests for electron/update-remote.cjs — the remote-detection helpers that
|
||||
* keep passive update checks off the SSH origin for official installs.
|
||||
*
|
||||
* Run with: node --test electron/update-remote.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Why this matters: a public install can carry
|
||||
* origin=git@github.com:NousResearch/hermes-agent.git. A background
|
||||
* `git fetch origin` then authenticates over SSH and, with a FIDO2/passkey
|
||||
* key, triggers an unexplained hardware-touch prompt. isOfficialSshRemote
|
||||
* must reliably recognize the official SSH remote (in every URL form,
|
||||
* case-insensitively) so the caller can swap in the anonymous HTTPS path —
|
||||
* while NOT misclassifying forks, other hosts, or the HTTPS remote (which
|
||||
* never prompts and should keep the normal fetch path).
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
OFFICIAL_REPO_CANONICAL,
|
||||
canonicalGitHubRemote,
|
||||
isSshRemote,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
|
||||
test('canonicalGitHubRemote normalizes SSH and HTTPS forms to the same value', () => {
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:NousResearch/hermes-agent'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Case-insensitive: an uppercased owner still canonicalizes to the same repo.
|
||||
assert.equal(canonicalGitHubRemote('git@github.com:nousresearch/hermes-agent.git'), OFFICIAL_REPO_CANONICAL)
|
||||
// Trailing slashes are stripped.
|
||||
assert.equal(canonicalGitHubRemote('https://github.com/NousResearch/hermes-agent/'), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
|
||||
test('canonicalGitHubRemote is empty for falsy input', () => {
|
||||
assert.equal(canonicalGitHubRemote(''), '')
|
||||
assert.equal(canonicalGitHubRemote(null), '')
|
||||
assert.equal(canonicalGitHubRemote(undefined), '')
|
||||
})
|
||||
|
||||
test('isSshRemote detects scp-like and ssh:// forms only', () => {
|
||||
assert.equal(isSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isSshRemote(''), false)
|
||||
assert.equal(isSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote is true only for the official repo over SSH', () => {
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent.git'), true)
|
||||
assert.equal(isOfficialSshRemote('git@github.com:NousResearch/hermes-agent'), true)
|
||||
assert.equal(isOfficialSshRemote('ssh://git@github.com/NousResearch/hermes-agent.git'), true)
|
||||
// Case-insensitive owner/repo match.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:nousresearch/hermes-agent.git'), true)
|
||||
})
|
||||
|
||||
test('isOfficialSshRemote does NOT match forks, other hosts, or HTTPS', () => {
|
||||
// A fork over SSH belongs to the user — fetching it is their own remote,
|
||||
// not the official upstream, so the SSH-avoidance swap must not apply.
|
||||
assert.equal(isOfficialSshRemote('git@github.com:someuser/hermes-agent.git'), false)
|
||||
// Same repo name on a different host is not the official repo.
|
||||
assert.equal(isOfficialSshRemote('git@gitlab.com:NousResearch/hermes-agent.git'), false)
|
||||
// HTTPS to the official repo never prompts for SSH/FIDO2, so it keeps the
|
||||
// normal fetch path — must not be flagged as an official SSH remote.
|
||||
assert.equal(isOfficialSshRemote('https://github.com/NousResearch/hermes-agent.git'), false)
|
||||
assert.equal(isOfficialSshRemote(''), false)
|
||||
assert.equal(isOfficialSshRemote(null), false)
|
||||
})
|
||||
|
||||
test('OFFICIAL_REPO_HTTPS_URL canonicalizes to OFFICIAL_REPO_CANONICAL', () => {
|
||||
// Invariant: the URL we substitute in must be the same repo we detect.
|
||||
assert.equal(canonicalGitHubRemote(OFFICIAL_REPO_HTTPS_URL), OFFICIAL_REPO_CANONICAL)
|
||||
})
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -24,7 +24,14 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
browseBackward,
|
||||
browseForward,
|
||||
@@ -130,6 +137,10 @@ interface QueueEditState {
|
||||
|
||||
const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))
|
||||
|
||||
// Quiet period after the last keystroke before persisting the draft;
|
||||
// unmount/pagehide flushes bypass it.
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 400
|
||||
|
||||
export function ChatBar({
|
||||
busy,
|
||||
cwd,
|
||||
@@ -171,6 +182,9 @@ export function ChatBar({
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const drainingQueueRef = useRef(false)
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -182,6 +196,8 @@ export function ChatBar({
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const queueEditRef = useRef(queueEdit)
|
||||
queueEditRef.current = queueEdit
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
@@ -1097,6 +1113,69 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
// on enter. Keyed writes are idempotent, so no skip-sentinel.
|
||||
useEffect(() => {
|
||||
const { attachments, text } = takeSessionDraft(activeQueueSessionKey)
|
||||
loadIntoComposer(text, attachments)
|
||||
|
||||
return () => {
|
||||
const editing = queueEditRef.current
|
||||
|
||||
if (editing?.sessionKey === activeQueueSessionKey) {
|
||||
stashAt(activeQueueSessionKey, editing.draft, editing.attachments)
|
||||
} else if (!isBrowsingHistory(sessionId)) {
|
||||
stashAt(activeQueueSessionKey)
|
||||
}
|
||||
}
|
||||
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Debounced stash into the active scope. Skipped while browsing history or
|
||||
// editing a queued prompt — recalled text must not clobber the real draft.
|
||||
useEffect(() => {
|
||||
if (isBrowsingHistory(sessionId) || queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = { scope: activeQueueSessionKey, text: draft }
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(activeQueueSessionKey, draft)
|
||||
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
||||
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [activeQueueSessionKey, draft, queueEdit, sessionId])
|
||||
|
||||
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
|
||||
// inside the debounce window would drop trailing keystrokes without this.
|
||||
useEffect(() => {
|
||||
const flushPendingDraftPersist = () => {
|
||||
const pending = pendingDraftPersistRef.current
|
||||
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingDraftPersistRef.current = null
|
||||
stashAt(pending.scope, pending.text)
|
||||
}
|
||||
|
||||
window.addEventListener('pagehide', flushPendingDraftPersist)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flushPendingDraftPersist)
|
||||
flushPendingDraftPersist()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const beginQueuedEdit = (entry: QueuedPromptEntry) => {
|
||||
if (!activeQueueSessionKey || queueEdit) {
|
||||
return
|
||||
@@ -1299,20 +1378,38 @@ export function ChatBar({
|
||||
}
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Clean up queue edit when its target disappears (session swap or external delete).
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
useEffect(() => {
|
||||
if (!queueEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey && editingQueuedPrompt) {
|
||||
return
|
||||
if (queueEdit.sessionKey === activeQueueSessionKey) {
|
||||
if (editingQueuedPrompt) {
|
||||
return
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
}
|
||||
|
||||
loadIntoComposer(queueEdit.draft, queueEdit.attachments)
|
||||
setQueueEdit(null)
|
||||
}, [activeQueueSessionKey, editingQueuedPrompt, queueEdit]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
|
||||
const submittedScope = activeQueueSessionKeyRef.current
|
||||
const submittedAttachments = attachments ?? []
|
||||
|
||||
const restore = () => {
|
||||
loadIntoComposer(text, submittedAttachments)
|
||||
stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)
|
||||
}
|
||||
|
||||
void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))
|
||||
.then(accepted => void (accepted === false ? restore() : clearSessionDraft(submittedScope)))
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
@@ -1323,8 +1420,10 @@ export function ChatBar({
|
||||
// input event; refresh it from the editor once more to also cover an
|
||||
// in-flight keystroke that hasn't fired its input event yet.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
aui.composer().setText(domText)
|
||||
@@ -1345,10 +1444,9 @@ export function ChatBar({
|
||||
// /send directives). Queuing them would make every slash command wait
|
||||
// for the current turn to finish, which is how the TUI never behaves.
|
||||
if (!attachments.length && SLASH_COMMAND_RE.test(text.trim())) {
|
||||
const submitted = text
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
void onSubmit(submitted)
|
||||
dispatchSubmit(text)
|
||||
} else if (payloadPresent) {
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
@@ -1360,12 +1458,12 @@ export function ChatBar({
|
||||
} else if (!payloadPresent && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
} else if (payloadPresent) {
|
||||
const submitted = text
|
||||
const submittedAttachments = cloneAttachments(attachments)
|
||||
triggerHaptic('submit')
|
||||
resetBrowseState(sessionId)
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
void onSubmit(submitted, { attachments })
|
||||
dispatchSubmit(text, submittedAttachments)
|
||||
}
|
||||
|
||||
focusInput()
|
||||
|
||||
@@ -42,6 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
@@ -102,8 +103,12 @@ function Harness({
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({ steerPrompt: actions.steerPrompt, submitText: actions.submitText })
|
||||
}, [actions.steerPrompt, actions.submitText, onReady])
|
||||
onReady({
|
||||
cancelRun: actions.cancelRun,
|
||||
steerPrompt: actions.steerPrompt,
|
||||
submitText: actions.submitText
|
||||
})
|
||||
}, [actions.cancelRun, actions.steerPrompt, actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -629,6 +634,43 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let interruptAttempts = 0
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'session.interrupt') {
|
||||
interruptAttempts += 1
|
||||
if (interruptAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
return {} as never
|
||||
}
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
await handle!.cancelRun()
|
||||
|
||||
expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
|
||||
})
|
||||
|
||||
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
|
||||
const calls: string[] = []
|
||||
const states: Record<string, unknown>[] = []
|
||||
@@ -818,4 +860,3 @@ describe('uploadComposerAttachment remote read failures', () => {
|
||||
).rejects.toThrow('ENOENT: no such file')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -108,6 +108,12 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
|
||||
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
|
||||
}
|
||||
|
||||
function isSessionNotFoundError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
return /session not found/i.test(message)
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
@@ -661,9 +667,7 @@ export function usePromptActions({
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
} catch (firstErr) {
|
||||
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
|
||||
|
||||
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
@@ -1273,11 +1277,39 @@ export function usePromptActions({
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch (err) {
|
||||
let stopError = err
|
||||
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current
|
||||
})
|
||||
const recoveredId = resumed?.session_id
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('session.interrupt', { session_id: recoveredId })
|
||||
|
||||
return
|
||||
}
|
||||
} catch (resumeErr) {
|
||||
stopError = resumeErr
|
||||
}
|
||||
}
|
||||
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
notifyError(err, copy.stopFailed)
|
||||
notifyError(stopError, copy.stopFailed)
|
||||
}
|
||||
}, [activeSessionId, activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, updateSessionState])
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
copy.stopFailed,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
|
||||
@@ -8,7 +8,6 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
workspaceCwdForNewSession,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setAwaitingResponse,
|
||||
@@ -41,7 +39,8 @@ import {
|
||||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
setYoloActive,
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
@@ -329,8 +328,7 @@ export function useSessionActions({
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
},
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
@@ -352,11 +350,13 @@ export function useSessionActions({
|
||||
// Pass the owning profile so a new chat under a non-launch profile (global
|
||||
// remote mode) builds its agent + persists against THAT profile's home/db.
|
||||
const newChatProfile = $newChatProfile.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
})
|
||||
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
@@ -475,8 +475,6 @@ export function useSessionActions({
|
||||
setCurrentCwd(cachedState.cwd)
|
||||
setCurrentBranch(cachedState.branch)
|
||||
setSessionStartedAt(Date.now())
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
|
||||
try {
|
||||
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
|
||||
@@ -606,8 +604,6 @@ export function useSessionActions({
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
@@ -730,8 +726,6 @@ export function useSessionActions({
|
||||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
clearComposerDraft()
|
||||
clearComposerAttachments()
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
@@ -872,6 +866,12 @@ export function useSessionActions({
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true, archived?.profile)
|
||||
// A sidebar refresh can race the optimistic removal while the PATCH is
|
||||
// in flight and briefly reinsert the still-unarchived backend row. Win
|
||||
// that race after the mutation succeeds so right-click → Archive does
|
||||
// not appear to do nothing until the next full refresh.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set($pinnedSessionIds.get().filter(id => id !== storedSessionId && id !== archivedPinId))
|
||||
notify({ durationMs: 2_000, kind: 'success', message: copy.archived })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
|
||||
@@ -3,8 +3,12 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
$composerAttachments,
|
||||
addComposerAttachment,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
removeComposerAttachment,
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
takeSessionDraft,
|
||||
updateComposerAttachment
|
||||
} from './composer'
|
||||
|
||||
@@ -41,3 +45,62 @@ describe('updateComposerAttachment', () => {
|
||||
expect($composerAttachments.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session drafts', () => {
|
||||
afterEach(() => {
|
||||
for (const scope of ['session-a', 'session-b', null]) {
|
||||
clearSessionDraft(scope)
|
||||
}
|
||||
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps drafts isolated per session scope', () => {
|
||||
stashSessionDraft('session-a', 'draft a', [])
|
||||
stashSessionDraft('session-b', 'draft b', [attachment({ id: 'image:b', kind: 'image' })])
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: 'draft a' })
|
||||
expect(takeSessionDraft('session-b').text).toBe('draft b')
|
||||
expect(takeSessionDraft('session-b').attachments.map(a => a.id)).toEqual(['image:b'])
|
||||
})
|
||||
|
||||
it('scopes the unsaved new-session draft separately from real sessions', () => {
|
||||
stashSessionDraft(null, 'new chat draft', [])
|
||||
stashSessionDraft('session-a', 'session draft', [])
|
||||
|
||||
expect(takeSessionDraft(null).text).toBe('new chat draft')
|
||||
expect(takeSessionDraft(undefined).text).toBe('new chat draft')
|
||||
expect(takeSessionDraft('session-a').text).toBe('session draft')
|
||||
})
|
||||
|
||||
it('persists draft text (not attachments) to localStorage', () => {
|
||||
stashSessionDraft('session-a', 'survives reload', [attachment({ id: 'file:a' })])
|
||||
|
||||
const persisted = JSON.parse(window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY) ?? '{}') as Record<string, string>
|
||||
|
||||
expect(persisted['session-a']).toBe('survives reload')
|
||||
})
|
||||
|
||||
it('evicts empty drafts instead of leaving stale entries behind', () => {
|
||||
stashSessionDraft('session-a', 'saved', [])
|
||||
stashSessionDraft('session-a', ' ', [])
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
|
||||
})
|
||||
|
||||
it('clears a stashed draft after an accepted submit', () => {
|
||||
stashSessionDraft('session-a', 'sent prompt', [attachment({ id: 'file:a' })])
|
||||
clearSessionDraft('session-a')
|
||||
|
||||
expect(takeSessionDraft('session-a')).toEqual({ attachments: [], text: '' })
|
||||
})
|
||||
|
||||
it('returns clones so callers cannot mutate the stash', () => {
|
||||
stashSessionDraft('session-a', 'draft', [attachment({ id: 'file:a' })])
|
||||
|
||||
const taken = takeSessionDraft('session-a')
|
||||
taken.attachments[0]!.label = 'mutated'
|
||||
|
||||
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,84 @@ export const $composerDraft = atom('')
|
||||
export const $composerAttachments = atom<ComposerAttachment[]>([])
|
||||
export const $composerTerminalSelections = atom<Record<string, string>>({})
|
||||
|
||||
// Per-thread draft stash for the decoupled composer. Session lifecycle never
|
||||
// touches this — only ChatBar's scope swap reads/writes it. Text mirrors to
|
||||
// localStorage; attachments are memory-only (blobs, upload state).
|
||||
export const SESSION_DRAFTS_STORAGE_KEY = 'hermes:composer-drafts:v3'
|
||||
|
||||
const NEW_SESSION_DRAFT_KEY = '__new__'
|
||||
const MAX_PERSISTED_DRAFTS = 50
|
||||
const EMPTY_SESSION_DRAFT: SessionDraft = { attachments: [], text: '' }
|
||||
|
||||
export interface SessionDraft {
|
||||
attachments: ComposerAttachment[]
|
||||
text: string
|
||||
}
|
||||
|
||||
const draftKey = (scope: string | null | undefined) => scope?.trim() || NEW_SESSION_DRAFT_KEY
|
||||
|
||||
const cloneDraft = (draft: SessionDraft): SessionDraft => ({
|
||||
attachments: draft.attachments.map(attachment => ({ ...attachment })),
|
||||
text: draft.text
|
||||
})
|
||||
|
||||
function loadPersistedDraftTexts(): [string, SessionDraft][] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SESSION_DRAFTS_STORAGE_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(JSON.parse(raw) as Record<string, string>).map(([key, text]) => [
|
||||
key,
|
||||
{ attachments: [], text }
|
||||
])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
|
||||
|
||||
function persistDraftTexts() {
|
||||
try {
|
||||
const entries = [...draftsBySession]
|
||||
.filter(([, draft]) => draft.text)
|
||||
.slice(-MAX_PERSISTED_DRAFTS)
|
||||
.map(([key, draft]) => [key, draft.text] as const)
|
||||
|
||||
if (entries.length === 0) {
|
||||
window.localStorage.removeItem(SESSION_DRAFTS_STORAGE_KEY)
|
||||
} else {
|
||||
window.localStorage.setItem(SESSION_DRAFTS_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only — quota/private-mode must never break typing.
|
||||
}
|
||||
}
|
||||
|
||||
export function stashSessionDraft(scope: string | null | undefined, text: string, attachments: ComposerAttachment[]) {
|
||||
const key = draftKey(scope)
|
||||
|
||||
// Delete-then-set keeps MRU order for MAX_PERSISTED_DRAFTS eviction.
|
||||
draftsBySession.delete(key)
|
||||
|
||||
if (text.trim() || attachments.length > 0) {
|
||||
draftsBySession.set(key, cloneDraft({ attachments, text }))
|
||||
}
|
||||
|
||||
persistDraftTexts()
|
||||
}
|
||||
|
||||
export function takeSessionDraft(scope: string | null | undefined): SessionDraft {
|
||||
const stashed = draftsBySession.get(draftKey(scope))
|
||||
|
||||
return stashed ? cloneDraft(stashed) : EMPTY_SESSION_DRAFT
|
||||
}
|
||||
|
||||
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
||||
@@ -133,13 +133,52 @@ describe('mergeSessionPage', () => {
|
||||
it('keeps a pinned session matched by its lineage root after compression', () => {
|
||||
// The pin is stored on the lineage-root id, but the loaded row surfaces
|
||||
// under its live compression tip. Matching on _lineage_root_id keeps it.
|
||||
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })]
|
||||
const incoming = [session({ id: 'other' })]
|
||||
const previous = [session({ id: 'tip', _lineage_root_id: 'root' })] as SessionInfo[]
|
||||
const incoming = [session({ id: 'other' })] as SessionInfo[]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, ['root'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
|
||||
})
|
||||
|
||||
it('evicts an old compression tip when the incoming page has the new tip from the same lineage', () => {
|
||||
// Repro of #43483: after auto-compression rotates the tip (#4 → #5),
|
||||
// the sidebar showed both the old tip and the new tip as separate rows.
|
||||
// The old tip must be evicted because its lineage key matches the incoming
|
||||
// new tip's lineage key.
|
||||
const previous = [
|
||||
session({ id: 'tip-4', _lineage_root_id: 'root' }),
|
||||
session({ id: 'other' }),
|
||||
] as SessionInfo[]
|
||||
const incoming = [
|
||||
session({ id: 'tip-5', _lineage_root_id: 'root' }),
|
||||
] as SessionInfo[]
|
||||
|
||||
// 'tip-4' is in the keep set (e.g. it was the active/working session),
|
||||
// but should still be evicted because the incoming page carries the same
|
||||
// lineage under a new tip id.
|
||||
const merged = mergeSessionPage(previous, incoming, ['tip-4'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['tip-5'])
|
||||
// The new tip comes from the server payload.
|
||||
expect(merged.find(s => s.id === 'tip-5')?._lineage_root_id).toBe('root')
|
||||
})
|
||||
|
||||
it('preserves an unrelated pinned session even when lineage dedup is active', () => {
|
||||
// Regression guard: lineage dedup must not accidentally evict sessions
|
||||
// from a different lineage that happen to be in the keep set.
|
||||
const previous = [
|
||||
session({ id: 'a-old', _lineage_root_id: 'lineage-a' }),
|
||||
session({ id: 'b', _lineage_root_id: 'lineage-b' }),
|
||||
] as SessionInfo[]
|
||||
const incoming = [
|
||||
session({ id: 'a-new', _lineage_root_id: 'lineage-a' }),
|
||||
] as SessionInfo[]
|
||||
|
||||
const merged = mergeSessionPage(previous, incoming, ['b'])
|
||||
|
||||
expect(merged.map(s => s.id)).toEqual(['b', 'a-new'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
|
||||
@@ -125,10 +125,18 @@ export function mergeSessionPage(
|
||||
}
|
||||
|
||||
const incomingIds = new Set(incoming.map(session => session.id))
|
||||
// Deduplicate by compression lineage: when auto-compression rotates the tip
|
||||
// id (old #4 → new #5), the incoming page carries the new tip but the
|
||||
// previous list still holds the old one. Without lineage-level dedup both
|
||||
// rows survive as separate sidebar entries (fixes #43483).
|
||||
const incomingLineageKeys = new Set(
|
||||
incoming.map(session => session._lineage_root_id ?? session.id)
|
||||
)
|
||||
|
||||
const survivors = previous.filter(
|
||||
session =>
|
||||
!incomingIds.has(session.id) &&
|
||||
!incomingLineageKeys.has(session._lineage_root_id ?? session.id) &&
|
||||
(keep.has(session.id) || (session._lineage_root_id != null && keep.has(session._lineage_root_id)))
|
||||
)
|
||||
|
||||
|
||||
@@ -3426,6 +3426,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# frozen when the agent thread completes, displayed in the status bar.
|
||||
self._prompt_start_time: Optional[float] = None # time.time() when turn started
|
||||
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
|
||||
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
|
||||
# Initialize SQLite session store early so /title works before first message
|
||||
self._session_db = None
|
||||
try:
|
||||
@@ -3812,6 +3813,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
emoji = "⏱" if live else "⏲"
|
||||
return f"{emoji} {time_str}"
|
||||
|
||||
@staticmethod
|
||||
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
|
||||
"""Format time since the last final agent response for the status bar.
|
||||
|
||||
Returns an empty string while a turn is live (the per-prompt elapsed
|
||||
timer covers that case) or before the first turn has completed.
|
||||
Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
|
||||
"""
|
||||
if turn_live or last_finished_at is None:
|
||||
return ""
|
||||
idle = max(0.0, time.time() - last_finished_at)
|
||||
return f"✓ {format_duration_compact(idle)}"
|
||||
|
||||
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
||||
# Prefer the agent's model name — it updates on fallback.
|
||||
# self.model reflects the originally configured model and never
|
||||
@@ -3835,6 +3849,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
getattr(self, "_prompt_duration", 0.0),
|
||||
live=getattr(self, "_prompt_start_time", None) is not None,
|
||||
),
|
||||
"idle_since": self._format_idle_since(
|
||||
getattr(self, "_last_turn_finished_at", None),
|
||||
turn_live=getattr(self, "_prompt_start_time", None) is not None,
|
||||
),
|
||||
"context_tokens": 0,
|
||||
"context_length": None,
|
||||
"context_percent": None,
|
||||
@@ -4146,6 +4164,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
prompt_elapsed = snapshot.get("prompt_elapsed")
|
||||
if prompt_elapsed:
|
||||
parts.append(prompt_elapsed)
|
||||
idle_since = snapshot.get("idle_since")
|
||||
if idle_since:
|
||||
parts.append(idle_since)
|
||||
if yolo_active:
|
||||
parts.append("⚠ YOLO")
|
||||
return self._trim_status_bar_text(" │ ".join(parts), width)
|
||||
@@ -4247,6 +4268,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if prompt_elapsed:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-dim", prompt_elapsed))
|
||||
# Position 8: idle time since the last final agent response
|
||||
idle_since = snapshot.get("idle_since")
|
||||
if idle_since:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-dim", idle_since))
|
||||
if yolo_active:
|
||||
frags.append(("class:status-bar-dim", " │ "))
|
||||
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
|
||||
@@ -5552,6 +5578,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
|
||||
)
|
||||
|
||||
quick_commands = self.config.get("quick_commands", {})
|
||||
if quick_commands:
|
||||
_cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
|
||||
for name, qcmd in sorted(quick_commands.items()):
|
||||
desc = qcmd.get("description", qcmd.get("type", ""))
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
|
||||
)
|
||||
|
||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||
@@ -5821,6 +5856,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
|
||||
"""Drop a just-ended session row when it never gained content.
|
||||
|
||||
Starting the CLI and immediately quitting (or rotating with /new,
|
||||
/clear) used to leave an empty untitled row behind that clutters
|
||||
``/resume`` and ``hermes sessions list``. Delegates the
|
||||
check-and-delete to ``SessionDB.delete_session_if_empty``, which
|
||||
only removes rows with no messages, no title, and no child
|
||||
sessions. Ported from google-gemini/gemini-cli#27770.
|
||||
"""
|
||||
if not self._session_db or not session_id:
|
||||
return False
|
||||
# In-memory transcript is authoritative: if this CLI object holds
|
||||
# conversation messages (flushed to the DB or not), the session is
|
||||
# not empty. Protects against pruning a real conversation whose DB
|
||||
# flush failed or hasn't happened yet.
|
||||
if getattr(self, "conversation_history", None):
|
||||
return False
|
||||
try:
|
||||
from hermes_constants import get_hermes_home as _ghh
|
||||
return self._session_db.delete_session_if_empty(
|
||||
session_id, sessions_dir=_ghh() / "sessions"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Could not prune empty session %s", session_id, exc_info=True
|
||||
)
|
||||
return False
|
||||
|
||||
def new_session(self, silent=False, title=None):
|
||||
"""Start a fresh session with a new session ID and cleared agent state."""
|
||||
if self.agent and self.conversation_history:
|
||||
@@ -5837,6 +5901,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._session_db.end_session(old_session_id, "new_session")
|
||||
except Exception:
|
||||
pass
|
||||
# Don't let immediately-rotated empty sessions pile up in
|
||||
# /resume and `hermes sessions list` (gemini-cli#27770 port).
|
||||
self._discard_session_if_empty(old_session_id)
|
||||
|
||||
self.session_start = datetime.now()
|
||||
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
|
||||
@@ -10121,6 +10188,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if self._prompt_start_time is not None:
|
||||
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
|
||||
self._prompt_start_time = None
|
||||
# Record when this agent loop finished so the status bar can show
|
||||
# idle time since the last final response.
|
||||
self._last_turn_finished_at = time.time()
|
||||
|
||||
# Proactively clean up async clients whose event loop is dead.
|
||||
# The agent thread may have created AsyncOpenAI clients bound
|
||||
@@ -13074,6 +13144,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._session_db.end_session(self.agent.session_id, "cli_close")
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not close session in DB: %s", e)
|
||||
# Started-and-immediately-quit sessions never gained content;
|
||||
# drop the empty row so /resume and `hermes sessions list`
|
||||
# stay clean (gemini-cli#27770 port). No-op for resumed or
|
||||
# titled sessions and anything with messages or children.
|
||||
if not getattr(self, '_delete_session_on_exit', False):
|
||||
try:
|
||||
self._discard_session_if_empty(self.agent.session_id)
|
||||
except (Exception, KeyboardInterrupt) as e:
|
||||
logger.debug("Could not prune empty session: %s", e)
|
||||
# /exit --delete: also remove the current session's transcripts
|
||||
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
|
||||
if getattr(self, '_delete_session_on_exit', False):
|
||||
@@ -13336,9 +13415,21 @@ def main(
|
||||
else:
|
||||
toolsets_list.append(str(t))
|
||||
else:
|
||||
# Use the shared resolver so MCP servers are included at runtime
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
|
||||
# Coding posture (base Hermes): with no explicit --toolsets, collapse
|
||||
# to the coding toolset (+ enabled MCP servers) when sitting in a code
|
||||
# workspace. See agent/coding_context.py.
|
||||
_coding = None
|
||||
try:
|
||||
from agent.coding_context import coding_selection
|
||||
_coding = coding_selection(platform="cli", config=CLI_CONFIG)
|
||||
except Exception:
|
||||
_coding = None
|
||||
if _coding is not None:
|
||||
toolsets_list = _coding
|
||||
else:
|
||||
# Use the shared resolver so MCP servers are included at runtime
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
|
||||
|
||||
parsed_skills = _parse_skills_argument(skills)
|
||||
|
||||
|
||||
+26
-7
@@ -1218,17 +1218,30 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
|
||||
allowed_users = matrix_cfg.get("allowed_users")
|
||||
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users, list):
|
||||
allowed_users = ",".join(str(v) for v in allowed_users)
|
||||
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
|
||||
allowed_rooms = matrix_cfg.get("allowed_rooms")
|
||||
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(allowed_rooms, list):
|
||||
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
|
||||
# allowed_rooms: if set, bot ONLY responds in these rooms (whitelist)
|
||||
ar = matrix_cfg.get("allowed_rooms")
|
||||
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
|
||||
if isinstance(ar, list):
|
||||
ar = ",".join(str(v) for v in ar)
|
||||
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
|
||||
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
|
||||
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
|
||||
if isinstance(ignore_patterns, list):
|
||||
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
|
||||
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
|
||||
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
|
||||
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
|
||||
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
|
||||
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
@@ -1497,8 +1510,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
matrix_password = os.getenv("MATRIX_PASSWORD", "")
|
||||
if matrix_password:
|
||||
matrix_config.extra["password"] = matrix_password
|
||||
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
|
||||
matrix_e2ee_mode = os.getenv("MATRIX_E2EE_MODE", "").strip().lower()
|
||||
matrix_e2ee = (
|
||||
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred")
|
||||
or os.getenv("MATRIX_ENCRYPTION", "").lower() in ("true", "1", "yes")
|
||||
)
|
||||
matrix_config.extra["encryption"] = matrix_e2ee
|
||||
if matrix_e2ee_mode:
|
||||
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
|
||||
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
|
||||
if matrix_device_id:
|
||||
matrix_config.extra["device_id"] = matrix_device_id
|
||||
|
||||
+1404
-286
File diff suppressed because it is too large
Load Diff
@@ -191,6 +191,22 @@ from gateway.platforms.base import (
|
||||
)
|
||||
|
||||
|
||||
def _file_content_hash(path: Path) -> str:
|
||||
"""Return the first 16 hex chars of the SHA-256 of *path*'s contents.
|
||||
|
||||
Used for the bridge staleness handshake: bridge.js reports its own
|
||||
source hash in ``/health`` (``scriptHash``), and the adapter compares
|
||||
it against the hash of bridge.js currently on disk. A mismatch means
|
||||
a long-lived bridge process is serving code from before an update.
|
||||
Returns ``""`` when the file can't be read.
|
||||
"""
|
||||
import hashlib
|
||||
try:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def check_whatsapp_requirements() -> bool:
|
||||
"""
|
||||
Check if WhatsApp dependencies are available.
|
||||
@@ -587,9 +603,21 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
logger.warning("[%s] Could not acquire session lock (non-fatal): %s", self.name, e)
|
||||
|
||||
try:
|
||||
# Auto-install npm dependencies if node_modules doesn't exist
|
||||
# Auto-install npm dependencies when node_modules is missing OR
|
||||
# package.json changed since the last install (e.g. after
|
||||
# `hermes update` bumps the Baileys pin). The stamp file records
|
||||
# the package.json hash of the last successful install.
|
||||
bridge_dir = bridge_path.parent
|
||||
if not (bridge_dir / "node_modules").exists():
|
||||
_pkg_json = bridge_dir / "package.json"
|
||||
_dep_stamp = bridge_dir / "node_modules" / ".hermes-pkg-hash"
|
||||
_pkg_hash = _file_content_hash(_pkg_json)
|
||||
_deps_fresh = False
|
||||
if (bridge_dir / "node_modules").exists():
|
||||
try:
|
||||
_deps_fresh = (_dep_stamp.read_text().strip() == _pkg_hash) and bool(_pkg_hash)
|
||||
except OSError:
|
||||
_deps_fresh = False
|
||||
if not _deps_fresh:
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
@@ -610,6 +638,11 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
print(f"[{self.name}] npm install failed: {install_result.stderr}")
|
||||
return False
|
||||
print(f"[{self.name}] Dependencies installed")
|
||||
if _pkg_hash:
|
||||
try:
|
||||
_dep_stamp.write_text(_pkg_hash)
|
||||
except OSError:
|
||||
pass # Stamp is an optimization; install still succeeded
|
||||
except Exception as e:
|
||||
print(f"[{self.name}] Failed to install dependencies: {e}")
|
||||
return False
|
||||
@@ -629,12 +662,28 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
data = await resp.json()
|
||||
bridge_status = data.get("status", "unknown")
|
||||
if bridge_status == "connected":
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
# Staleness handshake: only reuse a running
|
||||
# bridge if it is serving the same bridge.js
|
||||
# that is on disk right now. A long-lived
|
||||
# bridge survives gateway restarts AND
|
||||
# `hermes update`, so without this check it
|
||||
# keeps serving pre-update code forever
|
||||
# (e.g. no inbound media download). Old
|
||||
# bridges that don't report scriptHash are
|
||||
# treated as stale by definition.
|
||||
running_hash = data.get("scriptHash", "")
|
||||
disk_hash = _file_content_hash(bridge_path)
|
||||
if running_hash and disk_hash and running_hash == disk_hash:
|
||||
print(f"[{self.name}] Using existing bridge (status: {bridge_status})")
|
||||
self._mark_connected()
|
||||
self._bridge_process = None # Not managed by us
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
self._poll_task = asyncio.create_task(self._poll_messages())
|
||||
return True
|
||||
print(
|
||||
f"[{self.name}] Running bridge is stale "
|
||||
f"(running={running_hash or 'unversioned'}, disk={disk_hash}), restarting"
|
||||
)
|
||||
else:
|
||||
print(f"[{self.name}] Bridge found but not connected (status: {bridge_status}), restarting")
|
||||
except Exception:
|
||||
@@ -659,6 +708,18 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
bridge_env = os.environ.copy()
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
# Pass the profile-aware cache directories so the bridge writes
|
||||
# media where the Python side reads it. Without these the bridge
|
||||
# hardcodes ~/.hermes/{image,audio,document}_cache, which diverges
|
||||
# under HERMES_HOME overrides, profiles, and the new cache/ layout.
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir as _get_audio_dir,
|
||||
get_document_cache_dir as _get_doc_dir,
|
||||
get_image_cache_dir as _get_img_dir,
|
||||
)
|
||||
bridge_env["HERMES_IMAGE_CACHE_DIR"] = str(_get_img_dir())
|
||||
bridge_env["HERMES_AUDIO_CACHE_DIR"] = str(_get_audio_dir())
|
||||
bridge_env["HERMES_DOCUMENT_CACHE_DIR"] = str(_get_doc_dir())
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
|
||||
+98
-2
@@ -32,6 +32,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import site
|
||||
import sys
|
||||
import signal
|
||||
import tempfile
|
||||
@@ -135,6 +136,60 @@ _GATEWAY_SECRET_PATTERNS = (
|
||||
)
|
||||
|
||||
|
||||
def _ensure_windows_gateway_venv_imports() -> None:
|
||||
"""Make detached Windows gateway runs see the Hermes venv packages.
|
||||
|
||||
Some Windows restart paths run the gateway under uv's base ``pythonw.exe``
|
||||
to avoid the venv launcher respawning a visible console interpreter. That
|
||||
mode can import the source tree via cwd/PYTHONPATH but still miss optional
|
||||
packages installed only in ``venv/Lib/site-packages`` (notably the MCP SDK).
|
||||
Patch the live process before MCP discovery so tool injection does not
|
||||
depend on every launcher preserving PYTHONPATH perfectly.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
candidates: list[Path] = []
|
||||
if os.environ.get("VIRTUAL_ENV"):
|
||||
candidates.append(Path(os.environ["VIRTUAL_ENV"]))
|
||||
candidates.append(project_root / "venv")
|
||||
|
||||
seen: set[str] = set()
|
||||
for venv_dir in candidates:
|
||||
try:
|
||||
resolved_venv = venv_dir.resolve()
|
||||
except OSError:
|
||||
resolved_venv = venv_dir
|
||||
venv_key = str(resolved_venv).lower()
|
||||
if venv_key in seen:
|
||||
continue
|
||||
seen.add(venv_key)
|
||||
|
||||
site_packages = resolved_venv / "Lib" / "site-packages"
|
||||
if not site_packages.exists():
|
||||
continue
|
||||
|
||||
project_entry = str(project_root)
|
||||
site_entry = str(site_packages)
|
||||
if project_entry not in sys.path:
|
||||
sys.path.insert(0, project_entry)
|
||||
# addsitepackages() semantics matter here: pywin32, used by the MCP
|
||||
# SDK on Windows, relies on .pth processing to expose pywintypes.
|
||||
site.addsitedir(site_entry)
|
||||
if site_entry in sys.path:
|
||||
sys.path.remove(site_entry)
|
||||
insert_at = 1 if sys.path and sys.path[0] == project_entry else 0
|
||||
sys.path.insert(insert_at, site_entry)
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(resolved_venv)
|
||||
pythonpath = [project_entry, site_entry]
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
pythonpath.append(os.environ["PYTHONPATH"])
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
return
|
||||
|
||||
|
||||
def _gateway_platform_value(platform: Any) -> str:
|
||||
"""Return a normalized gateway platform value for enums or raw strings."""
|
||||
return str(getattr(platform, "value", platform) or "").strip().lower()
|
||||
@@ -4255,10 +4310,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
watcher_env = os.environ.copy()
|
||||
# This watcher is intentionally outside the running gateway. If it
|
||||
# inherits the gateway marker, `hermes gateway restart` refuses to
|
||||
# run as a self-restart loop guard and the gateway stays stopped.
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
venv_dir = Path(watcher_env.get("VIRTUAL_ENV") or project_root / "venv")
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
if site_packages.exists():
|
||||
watcher_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
pythonpath = [str(project_root), str(site_packages)]
|
||||
if watcher_env.get("PYTHONPATH"):
|
||||
pythonpath.append(watcher_env["PYTHONPATH"])
|
||||
watcher_env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(pythonpath))
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", watcher, str(current_pid), *cmd_argv],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
return
|
||||
@@ -4268,12 +4338,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
f"while kill -0 {current_pid} 2>/dev/null; do sleep 0.2; done; "
|
||||
f"{cmd} gateway restart"
|
||||
)
|
||||
# Same marker scrub as the Windows watcher above: this watcher runs
|
||||
# `hermes gateway restart` from outside the gateway, but it inherits
|
||||
# _HERMES_GATEWAY=1 from us, and the CLI's self-restart loop guard
|
||||
# refuses to run when that marker is set — silently (DEVNULL), so the
|
||||
# gateway stops and never comes back.
|
||||
watcher_env = os.environ.copy()
|
||||
watcher_env.pop("_HERMES_GATEWAY", None)
|
||||
setsid_bin = shutil.which("setsid")
|
||||
if setsid_bin:
|
||||
subprocess.Popen(
|
||||
[setsid_bin, "bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
else:
|
||||
@@ -4281,6 +4359,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
["bash", "-lc", shell_cmd],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=watcher_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
@@ -12932,6 +13011,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
# True when the previously enqueued progress line was a terminal
|
||||
# fenced code block — consecutive terminal calls then drop the
|
||||
# repeated "💻 terminal" header and render back-to-back blocks.
|
||||
last_was_terminal_block = [False]
|
||||
|
||||
# ── Discord voice "verbal ack before tool calls" ────────────────
|
||||
# When the bot is in a voice channel with the continuous mixer
|
||||
@@ -13088,7 +13171,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
):
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_cmd_full = args["command"].rstrip()
|
||||
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
|
||||
# Consecutive terminal calls: drop the repeated
|
||||
# "💻 terminal" header so back-to-back commands render as
|
||||
# adjacent code blocks under a single header.
|
||||
_block_header = (
|
||||
"" if last_was_terminal_block[0] else f"{emoji} {tool_name}\n"
|
||||
)
|
||||
_code_block_full = f"{_block_header}```\n{_cmd_full}\n```"
|
||||
# Single-line, capped preview for non-verbose modes.
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
@@ -13099,13 +13188,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_cmd_short = _cmd_short[:_cap - 3] + "..."
|
||||
elif _multiline:
|
||||
_cmd_short = _cmd_short + " ..."
|
||||
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
|
||||
_code_block_short = f"{_block_header}```\n{_cmd_short}\n```"
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if _code_block_full is not None:
|
||||
last_was_terminal_block[0] = True
|
||||
progress_queue.put(_code_block_full)
|
||||
return
|
||||
last_was_terminal_block[0] = False
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13130,6 +13221,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# fenced block (built above) instead of the truncated preview.
|
||||
if _code_block_short is not None:
|
||||
msg = _code_block_short
|
||||
last_was_terminal_block[0] = True
|
||||
elif preview:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
@@ -13137,8 +13229,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if len(preview) > _cap:
|
||||
preview = preview[:_cap - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
last_was_terminal_block[0] = False
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
last_was_terminal_block[0] = False
|
||||
|
||||
# Dedup: collapse consecutive identical progress messages.
|
||||
# Common with execute_code where models iterate with the same
|
||||
@@ -15895,6 +15989,8 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
atexit.register(remove_pid_file)
|
||||
atexit.register(release_gateway_runtime_lock)
|
||||
|
||||
_ensure_windows_gateway_venv_imports()
|
||||
|
||||
# MCP tool discovery — run in an executor so the asyncio event loop
|
||||
# stays responsive even when a configured MCP server is slow or
|
||||
# unreachable. discover_mcp_tools() uses a blocking 120s wait
|
||||
|
||||
@@ -294,6 +294,22 @@ def build_session_context_prompt(
|
||||
if context.source.chat_topic:
|
||||
lines.append(f"**Channel Topic:** {context.source.chat_topic}")
|
||||
|
||||
if context.source.platform == Platform.MATRIX:
|
||||
src = context.source
|
||||
room_name = src.chat_name or src.chat_id
|
||||
room_id = _hash_chat_id(src.chat_id) if redact_pii else src.chat_id
|
||||
lines.append("")
|
||||
lines.append(f"**Matrix Room:** {room_name}")
|
||||
lines.append(f"**Matrix Room ID:** {room_id}")
|
||||
if src.thread_id:
|
||||
thread_id = _hash_chat_id(src.thread_id) if redact_pii else src.thread_id
|
||||
lines.append(f"**Matrix Thread:** {thread_id}")
|
||||
lines.append(
|
||||
"**Matrix room boundary:** Treat this turn as scoped to the current "
|
||||
"Matrix room/thread only. Do not assume unresolved references are "
|
||||
"about other Matrix rooms or projects unless the user explicitly says so."
|
||||
)
|
||||
|
||||
# User identity.
|
||||
# In shared multi-user sessions (shared threads OR shared non-thread groups
|
||||
# when group_sessions_per_user=False), multiple users contribute to the same
|
||||
@@ -1264,6 +1280,17 @@ class SessionStore:
|
||||
entries.sort(key=lambda e: e.updated_at, reverse=True)
|
||||
|
||||
return entries
|
||||
|
||||
def lookup_by_session_id(self, session_id: str) -> Optional[SessionEntry]:
|
||||
"""Return the active session entry for a persisted session ID, if any."""
|
||||
if not session_id:
|
||||
return None
|
||||
with self._lock:
|
||||
self._ensure_loaded_locked()
|
||||
for entry in self._entries.values():
|
||||
if entry.session_id == session_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None:
|
||||
"""Append a message to a session's transcript (SQLite).
|
||||
|
||||
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
@@ -32,7 +33,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
from agent.i18n import t
|
||||
from gateway.config import HomeChannel, Platform, PlatformConfig
|
||||
from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType
|
||||
from gateway.session import build_session_key
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_cli.config import cfg_get
|
||||
from utils import (
|
||||
atomic_json_write,
|
||||
@@ -447,6 +448,22 @@ class GatewaySlashCommandsMixin:
|
||||
])
|
||||
if queue_depth:
|
||||
lines.append(t("gateway.status.queued", count=queue_depth))
|
||||
if source.platform == Platform.MATRIX:
|
||||
adapter = self.adapters.get(Platform.MATRIX)
|
||||
scope = getattr(adapter, "_matrix_session_scope", os.getenv("MATRIX_SESSION_SCOPE", "auto"))
|
||||
thread = source.thread_id or "none"
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.matrix_scope_header"),
|
||||
t("gateway.status.matrix_scope_room", room=source.chat_name or source.chat_id),
|
||||
t("gateway.status.matrix_scope_room_id", room_id=source.chat_id),
|
||||
t("gateway.status.matrix_scope_thread", thread_id=thread),
|
||||
t("gateway.status.matrix_scope_mode", scope=scope),
|
||||
t(
|
||||
"gateway.status.matrix_scope_key",
|
||||
session_key=self._redact_matrix_session_key(session_key),
|
||||
),
|
||||
])
|
||||
lines.extend([
|
||||
"",
|
||||
t("gateway.status.platforms", platforms=', '.join(connected_platforms)),
|
||||
@@ -454,6 +471,37 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _redact_matrix_session_key(session_key: str) -> str:
|
||||
"""Return a stable Matrix session-key fingerprint for shared room status."""
|
||||
text = str(session_key or "")
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
|
||||
return f"sha256:{digest}"
|
||||
|
||||
def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]:
|
||||
"""Best-effort origin lookup for gateway session IDs."""
|
||||
lookup = getattr(type(self.session_store), "lookup_by_session_id", None)
|
||||
if callable(lookup):
|
||||
entry = lookup(self.session_store, session_id)
|
||||
return getattr(entry, "origin", None) if entry is not None else None
|
||||
|
||||
# Test doubles and older stores may not expose the public lookup helper.
|
||||
# Keep the Matrix resume guard fail-closed if no origin can be resolved.
|
||||
entries = getattr(self.session_store, "_entries", {}) or {}
|
||||
for entry in entries.values():
|
||||
if getattr(entry, "session_id", None) == session_id:
|
||||
return getattr(entry, "origin", None)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_matrix_room(current: SessionSource, origin: Optional[SessionSource]) -> bool:
|
||||
return (
|
||||
origin is not None
|
||||
and origin.platform == Platform.MATRIX
|
||||
and current.platform == Platform.MATRIX
|
||||
and origin.chat_id == current.chat_id
|
||||
)
|
||||
|
||||
async def _handle_agents_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /agents command - list active agents and running tasks."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
@@ -2652,7 +2700,14 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
name = event.get_command_args().strip()
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
parts = shlex.split(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
allow_all = "--all" in parts
|
||||
allow_cross_room = "--cross-room" in parts
|
||||
name = " ".join(p for p in parts if p not in {"--all", "--cross-room"}).strip()
|
||||
|
||||
# Strip common outer brackets/quotes users may type literally from the
|
||||
# usage hint (e.g. ``/resume <abc123>``). Mirrors the CLI behavior.
|
||||
@@ -2673,11 +2728,24 @@ class GatewaySlashCommandsMixin:
|
||||
# List recent titled sessions for this user/platform
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
if not titled:
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
return t("gateway.resume.matrix_no_named_sessions")
|
||||
return t("gateway.resume.no_named_sessions")
|
||||
lines = [t("gateway.resume.list_header")]
|
||||
for idx, s in enumerate(titled[:10], start=1):
|
||||
title = s["title"]
|
||||
if source.platform == Platform.MATRIX and allow_all:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if origin:
|
||||
title = f"{title} — {origin.chat_name or origin.chat_id}"
|
||||
preview = s.get("preview", "")[:40]
|
||||
preview_part = t("gateway.resume.list_preview_suffix", preview=preview) if preview else ""
|
||||
lines.append(t("gateway.resume.list_item_numbered", index=idx, title=title, preview_part=preview_part))
|
||||
@@ -2691,6 +2759,13 @@ class GatewaySlashCommandsMixin:
|
||||
if name.isdigit():
|
||||
try:
|
||||
titled = _list_titled_sessions()
|
||||
if source.platform == Platform.MATRIX and not allow_all:
|
||||
scoped = []
|
||||
for s in titled:
|
||||
origin = self._gateway_session_origin_for_id(str(s.get("id") or ""))
|
||||
if self._same_matrix_room(source, origin):
|
||||
scoped.append(s)
|
||||
titled = scoped
|
||||
except Exception as e:
|
||||
logger.debug("Failed to list titled sessions for numeric resume: %s", e)
|
||||
return t("gateway.resume.list_failed", error=e)
|
||||
@@ -2717,6 +2792,17 @@ class GatewaySlashCommandsMixin:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to resolve resume continuation for %s: %s", target_id, e)
|
||||
|
||||
if source.platform == Platform.MATRIX:
|
||||
target_origin = self._gateway_session_origin_for_id(target_id)
|
||||
if not self._same_matrix_room(source, target_origin) and not allow_cross_room:
|
||||
if target_origin is None:
|
||||
return t("gateway.resume.matrix_blocked_no_origin", name=name)
|
||||
return t(
|
||||
"gateway.resume.matrix_blocked_other_room",
|
||||
room=target_origin.chat_name or target_origin.chat_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# Check if already on that session
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
if current_entry.session_id == target_id:
|
||||
@@ -2744,6 +2830,15 @@ class GatewaySlashCommandsMixin:
|
||||
# Count messages for context
|
||||
history = self.session_store.load_transcript(target_id)
|
||||
msg_count = len([m for m in history if m.get("role") == "user"]) if history else 0
|
||||
msg_part = f" ({msg_count} message{'s' if msg_count != 1 else ''})" if msg_count else ""
|
||||
|
||||
if source.platform == Platform.MATRIX and allow_cross_room:
|
||||
return t(
|
||||
"gateway.resume.matrix_cross_room_success",
|
||||
title=title,
|
||||
room=source.chat_name or source.chat_id,
|
||||
msg_part=msg_part,
|
||||
)
|
||||
if not msg_count:
|
||||
return t("gateway.resume.resumed_no_count", title=title)
|
||||
if msg_count == 1:
|
||||
|
||||
+29
-6
@@ -31,6 +31,9 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory names to skip entirely (matched against each path component)
|
||||
# ``hermes-agent`` is special-cased to root level only in ``_should_exclude``
|
||||
# so that skill directories like ``skills/autonomous-ai-agents/hermes-agent/``
|
||||
# are not accidentally excluded.
|
||||
_EXCLUDED_DIRS = {
|
||||
"hermes-agent", # the codebase repo — re-clone instead
|
||||
"__pycache__", # bytecode caches — regenerated on import
|
||||
@@ -69,10 +72,15 @@ def _should_exclude(rel_path: Path) -> bool:
|
||||
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
|
||||
parts = rel_path.parts
|
||||
|
||||
# Any path component matches an excluded dir name
|
||||
for part in parts:
|
||||
if part in _EXCLUDED_DIRS:
|
||||
return True
|
||||
if part not in _EXCLUDED_DIRS:
|
||||
continue
|
||||
# ``hermes-agent`` only matches at the root level (first component).
|
||||
# Nested directories with the same name — e.g.
|
||||
# ``skills/autonomous-ai-agents/hermes-agent/`` — must be preserved.
|
||||
if part == "hermes-agent" and part != parts[0]:
|
||||
continue
|
||||
return True
|
||||
|
||||
name = rel_path.name
|
||||
|
||||
@@ -177,10 +185,13 @@ def run_backup(args) -> None:
|
||||
rel_dir = dp.relative_to(hermes_root)
|
||||
|
||||
# Prune excluded directories in-place so os.walk doesn't descend
|
||||
# ``hermes-agent`` is only pruned at the root level; nested dirs
|
||||
# with the same name (e.g. in skills/) must be preserved.
|
||||
is_root = rel_dir == Path(".")
|
||||
orig_dirnames = dirnames[:]
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in _EXCLUDED_DIRS
|
||||
if d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root)
|
||||
]
|
||||
for removed in set(orig_dirnames) - set(dirnames):
|
||||
skipped_dirs.add(str(rel_dir / removed))
|
||||
@@ -211,7 +222,13 @@ def run_backup(args) -> None:
|
||||
try:
|
||||
# Safe copy for SQLite databases (handles WAL mode)
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
zf.write(tmp_db, arcname=str(rel_path))
|
||||
@@ -853,7 +870,13 @@ def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
|
||||
for abs_path, rel_path in files_to_add:
|
||||
try:
|
||||
if abs_path.suffix == ".db":
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
# Stage the snapshot alongside the output zip so that the
|
||||
# temp file lives on the same filesystem. The system
|
||||
# default (/tmp) may be a small tmpfs that cannot hold
|
||||
# large databases, causing silent backup incompleteness.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".db", delete=False, dir=str(out_path.parent)
|
||||
) as tmp:
|
||||
tmp_db = Path(tmp.name)
|
||||
try:
|
||||
if _safe_copy_db(abs_path, tmp_db):
|
||||
|
||||
@@ -11,6 +11,7 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
@@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600
|
||||
UPDATE_AVAILABLE_NO_COUNT = -1
|
||||
|
||||
_UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git"
|
||||
_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent"
|
||||
|
||||
|
||||
def _canonical_github_remote(url: str | None) -> str:
|
||||
"""Return ``host/owner/repo`` for common GitHub remote URL forms."""
|
||||
if not url:
|
||||
return ""
|
||||
value = url.strip()
|
||||
if value.startswith("git@github.com:"):
|
||||
value = "github.com/" + value[len("git@github.com:"):]
|
||||
elif value.startswith("ssh://git@github.com/"):
|
||||
value = "github.com/" + value[len("ssh://git@github.com/"):]
|
||||
else:
|
||||
parsed = urlparse(value)
|
||||
if parsed.netloc and parsed.path:
|
||||
value = f"{parsed.netloc}{parsed.path}"
|
||||
value = value.strip().rstrip("/")
|
||||
if value.endswith(".git"):
|
||||
value = value[:-4]
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _is_ssh_remote(url: str | None) -> bool:
|
||||
if not url:
|
||||
return False
|
||||
value = url.strip().lower()
|
||||
return value.startswith("git@") or value.startswith("ssh://")
|
||||
|
||||
|
||||
def _is_official_ssh_remote(url: str | None) -> bool:
|
||||
return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL
|
||||
|
||||
|
||||
def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(cwd),
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
|
||||
def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
@@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]:
|
||||
|
||||
def _check_via_local_git(repo_dir: Path) -> Optional[int]:
|
||||
"""Count commits behind origin/main in a local checkout."""
|
||||
origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir)
|
||||
if _is_official_ssh_remote(origin_url):
|
||||
head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir)
|
||||
return _check_via_rev(head_rev) if head_rev else None
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", "--quiet"],
|
||||
|
||||
@@ -863,6 +863,19 @@ DEFAULT_CONFIG = {
|
||||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||||
# env var overrides this (build-time/container mechanism).
|
||||
"environment_hint": "",
|
||||
# Coding posture — on interactive coding surfaces (CLI, TUI, desktop
|
||||
# app, ACP) in a code workspace, Hermes adds a coding operating brief
|
||||
# + a live git/workspace snapshot to the system prompt. See
|
||||
# agent/coding_context.py.
|
||||
# "auto" (default) — prompt-only posture when the surface is
|
||||
# interactive AND cwd is a code workspace.
|
||||
# Toolsets are never touched; messaging platforms
|
||||
# unaffected.
|
||||
# "focus" — auto + collapse the toolset to the lean coding
|
||||
# set (+ enabled MCP servers). Explicit opt-in.
|
||||
# "on" — force the prompt posture everywhere.
|
||||
# "off" — disable entirely.
|
||||
"coding_context": "auto",
|
||||
# Staged inactivity warning: send a warning to the user at this
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
|
||||
+146
-8
@@ -612,13 +612,54 @@ def find_profile_gateway_processes(
|
||||
|
||||
|
||||
def _gateway_run_args_for_profile(profile: str) -> list[str]:
|
||||
args = [get_python_path(), "-m", "hermes_cli.main"]
|
||||
python_exe = get_python_path()
|
||||
if is_windows():
|
||||
# uv-created venv launchers are a trap here: ``venv\Scripts\pythonw.exe``
|
||||
# starts hidden but then re-execs the *base* interpreter as a console
|
||||
# ``python.exe`` — and that re-exec is a fresh CreateProcess that does
|
||||
# NOT inherit our CREATE_NO_WINDOW flag, so a blank console window pops
|
||||
# up. That's exactly what users hit when the gateway is respawned after
|
||||
# a Desktop-GUI ``hermes update``. Resolve the base ``pythonw.exe``
|
||||
# directly — the same path ``_spawn_detached`` / ``_build_gateway_argv``
|
||||
# take for ``hermes gateway start`` — so the post-update respawn is
|
||||
# windowless. The matching VIRTUAL_ENV / PYTHONPATH overlay is applied
|
||||
# to the spawn env in ``launch_detached_profile_gateway_restart`` so
|
||||
# imports still resolve without the venv launcher shim.
|
||||
from hermes_cli.gateway_windows import _resolve_detached_python
|
||||
|
||||
python_exe, _venv_dir, _extra_pythonpath = _resolve_detached_python(python_exe)
|
||||
args = [python_exe, "-m", "hermes_cli.main"]
|
||||
if profile != "default":
|
||||
args.extend(["--profile", profile])
|
||||
args.extend(["gateway", "run", "--replace"])
|
||||
return args
|
||||
|
||||
|
||||
def _gateway_respawn_env(spawn_env: dict[str, str]) -> dict[str, str]:
|
||||
"""Overlay VIRTUAL_ENV / PYTHONPATH so a base-``pythonw.exe`` respawn can
|
||||
import ``hermes_cli`` without the venv launcher shim.
|
||||
|
||||
Returns ``spawn_env`` unchanged on non-Windows so the POSIX spawn path is
|
||||
byte-for-byte identical to the pre-fix behaviour (it inherits ``os.environ``
|
||||
exactly as before). On Windows it mirrors what ``_build_gateway_argv`` does
|
||||
for ``hermes gateway start``: point VIRTUAL_ENV at the venv and prepend the
|
||||
repo root plus base-interpreter site-packages to PYTHONPATH.
|
||||
"""
|
||||
if not is_windows():
|
||||
return spawn_env
|
||||
from hermes_cli.gateway_windows import (
|
||||
_prepend_pythonpath,
|
||||
_resolve_detached_python,
|
||||
)
|
||||
|
||||
_python, venv_dir, extra_pythonpath = _resolve_detached_python(get_python_path())
|
||||
spawn_env["VIRTUAL_ENV"] = str(venv_dir)
|
||||
spawn_env["PYTHONIOENCODING"] = "utf-8"
|
||||
spawn_env["HERMES_GATEWAY_DETACHED"] = "1"
|
||||
_prepend_pythonpath(spawn_env, [str(PROJECT_ROOT), *extra_pythonpath])
|
||||
return spawn_env
|
||||
|
||||
|
||||
def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
"""Relaunch a manually-run profile gateway after its current PID exits."""
|
||||
if old_pid <= 0:
|
||||
@@ -703,14 +744,33 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
"""
|
||||
).strip()
|
||||
|
||||
# Resolve the watcher interpreter to the windowless base ``pythonw.exe``
|
||||
# on Windows for the same reason as the respawned gateway (see
|
||||
# ``_gateway_run_args_for_profile``): ``sys.executable`` during a
|
||||
# GUI-driven ``hermes update`` is a console ``python.exe`` whose uv venv
|
||||
# launcher re-execs a visible console. ``_resolve_detached_python`` is a
|
||||
# no-op shape on non-Windows callers because we only consult it under the
|
||||
# ``is_windows()`` guard below.
|
||||
watcher_python = sys.executable
|
||||
if is_windows():
|
||||
from hermes_cli.gateway_windows import _resolve_detached_python
|
||||
|
||||
watcher_python, _wv, _wpp = _resolve_detached_python(sys.executable)
|
||||
|
||||
watcher_argv = [
|
||||
sys.executable,
|
||||
watcher_python,
|
||||
"-c",
|
||||
watcher,
|
||||
str(old_pid),
|
||||
*_gateway_run_args_for_profile(profile),
|
||||
]
|
||||
|
||||
# The watcher inherits this env and the respawned gateway inherits it from
|
||||
# the watcher, so the base-``pythonw.exe`` legs can import ``hermes_cli``
|
||||
# without the venv launcher shim. No-op on POSIX (returns os.environ copy
|
||||
# unchanged), preserving the pre-fix spawn behaviour bit-for-bit there.
|
||||
spawn_env = _gateway_respawn_env(dict(os.environ))
|
||||
|
||||
# Same platform-aware detach for the watcher process itself — so
|
||||
# closing the user's terminal doesn't kill the watcher.
|
||||
try:
|
||||
@@ -718,6 +778,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=spawn_env,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
except OSError:
|
||||
@@ -736,6 +797,7 @@ def launch_detached_profile_gateway_restart(profile: str, old_pid: int) -> bool:
|
||||
watcher_argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=spawn_env,
|
||||
**fallback_kwargs,
|
||||
)
|
||||
except OSError:
|
||||
@@ -2531,6 +2593,65 @@ def systemd_unit_is_current(system: bool = False) -> bool:
|
||||
return norm_installed == norm_expected
|
||||
|
||||
|
||||
def _temp_home_in_service_definition(definition: str) -> str | None:
|
||||
"""Return the temp-dir HERMES_HOME baked into a service definition, or None.
|
||||
|
||||
A generated systemd unit / launchd plist carries the resolved HERMES_HOME
|
||||
in its environment block. If that path lives under the system temp dir,
|
||||
the definition was almost certainly generated by a test/E2E harness that
|
||||
exported a throwaway ``HERMES_HOME=/tmp/...`` — writing it to the real
|
||||
service file silently breaks the user's gateway on the next (re)start:
|
||||
the gateway comes back "active (running)" but pointed at an empty temp
|
||||
home ("No messaging platforms enabled"), deaf to every platform.
|
||||
Seen live 2026-06-11: an E2E guard probe ran ``hermes gateway restart``
|
||||
with ``HERMES_HOME=/tmp/hermes-e2e-<pr>`` exported; the restart path's
|
||||
unit refresh baked the temp path into the production unit and the
|
||||
post-update restart produced a zombie gateway for 7+ hours.
|
||||
|
||||
Matches both systemd ``Environment="HERMES_HOME=..."`` lines and launchd
|
||||
``<key>HERMES_HOME</key><string>...</string>`` pairs.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
|
||||
candidates += re.findall(
|
||||
r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S
|
||||
)
|
||||
temp_roots = {
|
||||
Path(tempfile.gettempdir()).resolve(),
|
||||
Path("/tmp"),
|
||||
Path("/var/tmp"),
|
||||
Path("/private/tmp"),
|
||||
Path("/private/var/tmp"),
|
||||
}
|
||||
for raw in candidates:
|
||||
try:
|
||||
resolved = Path(raw.strip().strip('"')).resolve()
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for root in temp_roots:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _refuse_temp_home_service_write(definition: str, kind: str) -> bool:
|
||||
"""Refuse (with guidance) when a service definition carries a temp HERMES_HOME."""
|
||||
temp_home = _temp_home_in_service_definition(definition)
|
||||
if temp_home is None:
|
||||
return False
|
||||
print(
|
||||
f"✗ Refusing to write the gateway {kind}: HERMES_HOME resolves to a "
|
||||
f"temporary directory ({temp_home})."
|
||||
)
|
||||
print(
|
||||
" This usually means a test/E2E environment exported HERMES_HOME. "
|
||||
"Unset it (or run from a clean shell) and retry."
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
"""Rewrite the installed systemd unit when the generated definition has changed."""
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
@@ -2561,6 +2682,12 @@ def refresh_systemd_unit_if_needed(system: bool = False) -> bool:
|
||||
):
|
||||
return False
|
||||
|
||||
# Structural variant of the same belt: refuse to bake ANY temp-dir
|
||||
# HERMES_HOME into the unit (manual E2E homes like /tmp/hermes-e2e-NNN
|
||||
# don't carry the pytest markers above but poison the unit identically).
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return False
|
||||
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
print(
|
||||
@@ -2729,10 +2856,11 @@ def systemd_install(
|
||||
return
|
||||
|
||||
unit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_unit = generate_systemd_unit(system=system, run_as_user=run_as_user)
|
||||
if _refuse_temp_home_service_write(new_unit, "systemd unit"):
|
||||
return
|
||||
print(f"Installing {_service_scope_label(system)} systemd service to: {unit_path}")
|
||||
unit_path.write_text(
|
||||
generate_systemd_unit(system=system, run_as_user=run_as_user), encoding="utf-8"
|
||||
)
|
||||
unit_path.write_text(new_unit, encoding="utf-8")
|
||||
|
||||
_run_systemctl(["daemon-reload"], system=system, check=True, timeout=30)
|
||||
if enable_on_startup:
|
||||
@@ -3362,7 +3490,11 @@ def refresh_launchd_plist_if_needed() -> bool:
|
||||
if not plist_path.exists() or launchd_plist_is_current():
|
||||
return False
|
||||
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return False
|
||||
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
label = get_launchd_label()
|
||||
# Bootout/bootstrap so launchd picks up the new definition
|
||||
subprocess.run(
|
||||
@@ -3395,8 +3527,11 @@ def launchd_install(force: bool = False):
|
||||
return
|
||||
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
return
|
||||
print(f"Installing launchd service to: {plist_path}")
|
||||
plist_path.write_text(generate_launchd_plist())
|
||||
plist_path.write_text(new_plist)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -3442,9 +3577,12 @@ def launchd_start():
|
||||
|
||||
# Self-heal if the plist is missing entirely (e.g., manual cleanup, failed upgrade)
|
||||
if not plist_path.exists():
|
||||
new_plist = generate_launchd_plist()
|
||||
if _refuse_temp_home_service_write(new_plist, "launchd plist"):
|
||||
sys.exit(1)
|
||||
print("↻ launchd plist missing; regenerating service definition")
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.write_text(generate_launchd_plist(), encoding="utf-8")
|
||||
plist_path.write_text(new_plist, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
|
||||
|
||||
+83
-2
@@ -1623,7 +1623,11 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
npm_cwd = _workspace_root(tui_dir)
|
||||
# --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
|
||||
# See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
|
||||
# When ui-tui/ has its own package-lock.json (e.g. curl install),
|
||||
# _workspace_root() returns tui_dir itself. Passing --workspace in
|
||||
# that case fails because npm cannot find a workspace named "ui-tui"
|
||||
# inside ui-tui/. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else ("--workspace", "ui-tui")
|
||||
if termux_startup:
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(
|
||||
tui_dir,
|
||||
@@ -4642,7 +4646,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
# graph (including apps/desktop with its Electron + node-pty deps) is never
|
||||
# resolved here. Without --workspace the root package.json's apps/* glob
|
||||
# would pull in desktop on every web build. See #38772.
|
||||
npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
|
||||
# When web/ has its own package-lock.json, _workspace_root() returns
|
||||
# web_dir itself and --workspace would fail. See #42973.
|
||||
npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else ("--workspace", "web")
|
||||
if _is_termux_startup_environment():
|
||||
npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
|
||||
r1 = _run_npm_install_deterministic(
|
||||
@@ -10214,6 +10220,21 @@ def _report_dashboard_status() -> int:
|
||||
return len(pids)
|
||||
|
||||
|
||||
def _dashboard_listening(host: str, port: int) -> bool:
|
||||
"""True when something is accepting TCP connections at host:port.
|
||||
|
||||
Any listener counts — even a 401 response proves a dashboard is up.
|
||||
Used by the unified profile-launch routing to decide attach-vs-start.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host or "127.0.0.1", port), timeout=1.5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_dashboard(args):
|
||||
"""Start the web UI server, or (with --stop/--status) manage running ones."""
|
||||
# --status: report running dashboards and exit, no deps needed.
|
||||
@@ -10234,6 +10255,65 @@ def cmd_dashboard(args):
|
||||
remaining = _find_stale_dashboard_pids()
|
||||
sys.exit(1 if remaining else 0)
|
||||
|
||||
# ── Unified profile launch routing ────────────────────────────────
|
||||
# The dashboard is a MACHINE management surface: it can read/write any
|
||||
# profile via the per-request ?profile= scoping. Running one dashboard
|
||||
# per profile just fragments that (port collisions, N processes, and a
|
||||
# "which dashboard am I on?" guessing game). So when a NAMED profile
|
||||
# launches the dashboard (`worker dashboard` → HERMES_HOME points into
|
||||
# profiles/), default to the machine dashboard:
|
||||
# - already running → open the browser at ?profile=<name> and exit
|
||||
# - not running → re-exec as the machine dashboard (pinned to the
|
||||
# default profile so _apply_profile_override can't re-route through
|
||||
# the sticky active_profile file) with the launching profile
|
||||
# preselected in the UI's switcher.
|
||||
# `--isolated` opts out and preserves the old per-profile behavior.
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
_launch_profile = get_active_profile_name()
|
||||
except Exception:
|
||||
_launch_profile = "default"
|
||||
|
||||
if (
|
||||
_launch_profile not in ("default", "custom")
|
||||
and not getattr(args, "isolated", False)
|
||||
and not getattr(args, "open_profile", "")
|
||||
):
|
||||
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
|
||||
if _dashboard_listening(args.host, args.port):
|
||||
print(f"Machine dashboard already running on port {args.port}.")
|
||||
print(f" Managing profile '{_launch_profile}': {url}")
|
||||
if not args.no_open:
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
print(
|
||||
f"Routing to the machine dashboard (profile '{_launch_profile}' "
|
||||
f"preselected). Use --isolated for a dedicated per-profile server."
|
||||
)
|
||||
reexec_argv = [
|
||||
sys.executable, "-m", "hermes_cli.main",
|
||||
"-p", "default",
|
||||
"dashboard",
|
||||
"--port", str(args.port),
|
||||
"--host", args.host,
|
||||
"--open-profile", _launch_profile,
|
||||
]
|
||||
if args.no_open:
|
||||
reexec_argv.append("--no-open")
|
||||
if getattr(args, "insecure", False):
|
||||
reexec_argv.append("--insecure")
|
||||
if getattr(args, "skip_build", False):
|
||||
reexec_argv.append("--skip-build")
|
||||
env = os.environ.copy()
|
||||
# Drop the profile HERMES_HOME so the child binds the machine root.
|
||||
env.pop("HERMES_HOME", None)
|
||||
os.execvpe(sys.executable, reexec_argv, env)
|
||||
|
||||
# Attach gui.log early so dashboard startup/build failures are captured in
|
||||
# the same logs directory as every other Hermes surface.
|
||||
try:
|
||||
@@ -10307,6 +10387,7 @@ def cmd_dashboard(args):
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
allow_public=getattr(args, "insecure", False),
|
||||
initial_profile=getattr(args, "open_profile", "") or "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,26 @@ def build_dashboard_parser(
|
||||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help=(
|
||||
"When launched from a named profile (e.g. `worker dashboard`), run "
|
||||
"a dedicated dashboard server scoped to that profile instead of "
|
||||
"routing to the machine dashboard. Default behavior is unified: "
|
||||
"profile launches attach to (or start) ONE machine-level dashboard "
|
||||
"and preselect the profile in the UI's profile switcher."
|
||||
),
|
||||
)
|
||||
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
|
||||
# preselect the launching profile in the SPA switcher. Hidden from
|
||||
# --help: users get this behavior automatically via `<profile> dashboard`.
|
||||
dashboard_parser.add_argument(
|
||||
"--open-profile",
|
||||
dest="open_profile",
|
||||
default="",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
|
||||
@@ -1437,6 +1437,10 @@ def _get_platform_tools(
|
||||
continue
|
||||
if ts_def.get("includes"):
|
||||
continue
|
||||
# Posture toolsets (e.g. ``coding``) are session-level selections made
|
||||
# by agent/coding_context.py — not per-platform capabilities to recover.
|
||||
if ts_def.get("posture"):
|
||||
continue
|
||||
ts_tools = set(resolve_toolset(ts_key))
|
||||
if not ts_tools or not ts_tools.issubset(platform_tool_universe):
|
||||
continue
|
||||
|
||||
+621
-220
File diff suppressed because it is too large
Load Diff
+83
-5
@@ -1715,15 +1715,51 @@ class SessionDB:
|
||||
"""Archive or unarchive a session.
|
||||
|
||||
Archived sessions are hidden from the default session list but keep all
|
||||
their messages — this is a soft hide, not a delete. Returns True when a
|
||||
row was updated.
|
||||
their messages — this is a soft hide, not a delete. For compression
|
||||
chains, archive the whole logical conversation. Desktop lists compression
|
||||
roots projected forward to their latest continuation; updating only the
|
||||
displayed tip lets the still-unarchived root resurrect it on refresh.
|
||||
Returns True when at least one row was updated.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"UPDATE sessions SET archived = ? WHERE id = ?",
|
||||
(1 if archived else 0, session_id),
|
||||
"""
|
||||
WITH RECURSIVE
|
||||
ancestors(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT parent.id
|
||||
FROM ancestors a
|
||||
JOIN sessions child ON child.id = a.id
|
||||
JOIN sessions parent ON parent.id = child.parent_session_id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
AND child.started_at >= parent.ended_at
|
||||
),
|
||||
descendants(id) AS (
|
||||
SELECT ?
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM descendants d
|
||||
JOIN sessions parent ON parent.id = d.id
|
||||
JOIN sessions child ON child.parent_session_id = parent.id
|
||||
WHERE parent.end_reason = 'compression'
|
||||
AND child.started_at >= parent.ended_at
|
||||
),
|
||||
lineage(id) AS (
|
||||
SELECT id FROM ancestors
|
||||
UNION
|
||||
SELECT id FROM descendants
|
||||
)
|
||||
UPDATE sessions
|
||||
SET archived = ?
|
||||
WHERE id IN (SELECT id FROM lineage)
|
||||
""",
|
||||
(session_id, session_id, 1 if archived else 0),
|
||||
)
|
||||
return cursor.rowcount
|
||||
rowcount = cursor.rowcount
|
||||
if rowcount is None or rowcount < 0:
|
||||
rowcount = conn.execute("SELECT changes()").fetchone()[0]
|
||||
return rowcount
|
||||
rowcount = self._execute_write(_do)
|
||||
return rowcount > 0
|
||||
|
||||
@@ -3658,6 +3694,48 @@ class SessionDB:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return deleted
|
||||
|
||||
def delete_session_if_empty(
|
||||
self,
|
||||
session_id: str,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""Delete *session_id* only when it never gained resumable content.
|
||||
|
||||
A session is considered empty when it has no messages and no
|
||||
user-assigned title. Used by CLI exit / session-rotation paths so
|
||||
immediately-started-and-quit sessions don't pile up in ``/resume``
|
||||
and ``hermes sessions list`` output. (Pattern ported from
|
||||
google-gemini/gemini-cli#27770.)
|
||||
|
||||
The emptiness check and delete run in one transaction, so a message
|
||||
flushed concurrently by another writer can't be lost. Sessions with
|
||||
children (delegate subagent runs) are preserved — a parent that
|
||||
spawned work is not "empty" even if its own transcript never
|
||||
flushed. Returns True if the session was deleted.
|
||||
"""
|
||||
def _do(conn):
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
DELETE FROM sessions
|
||||
WHERE id = ?
|
||||
AND title IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sessions child
|
||||
WHERE child.parent_session_id = sessions.id
|
||||
)
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
deleted = self._execute_write(_do)
|
||||
if deleted:
|
||||
self._remove_session_files(sessions_dir, session_id)
|
||||
return bool(deleted)
|
||||
|
||||
def delete_sessions(
|
||||
self,
|
||||
session_ids: List[str],
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Sessie-databasis is nie beskikbaar nie."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Geen benoemde sessies gevind nie.\nGebruik `/title My Sessie` om jou huidige sessie 'n naam te gee, en dan `/resume My Sessie` om later daarheen terug te keer."
|
||||
list_header: "📋 **Benoemde Sessies**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Sessie-ID:** `{session_id}`"
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Geskep:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Sitzungsdatenbank nicht verfügbar."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Keine benannten Sitzungen gefunden.\nVerwenden Sie `/title Meine Sitzung`, um die aktuelle Sitzung zu benennen, dann `/resume Meine Sitzung`, um später dorthin zurückzukehren."
|
||||
list_header: "📋 **Benannte Sitzungen**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes-Gateway-Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Sitzungs-ID:** `{session_id}`"
|
||||
title: "**Titel:** {title}"
|
||||
created: "**Erstellt:** {timestamp}"
|
||||
|
||||
@@ -234,6 +234,11 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Session database not available."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.\nUse quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.\nUse `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.\nFuture messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "No named sessions found.\nUse `/title My Session` to name your current session, then `/resume My Session` to return to it later."
|
||||
list_header: "📋 **Named Sessions**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -266,6 +271,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway Status**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Session ID:** `{session_id}`"
|
||||
title: "**Title:** {title}"
|
||||
created: "**Created:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Base de datos de sesiones no disponible."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "No se encontraron sesiones con nombre.\nUsa `/title Mi sesión` para nombrar la sesión actual y luego `/resume Mi sesión` para volver a ella."
|
||||
list_header: "📋 **Sesiones con nombre**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Estado de Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID de sesión:** `{session_id}`"
|
||||
title: "**Título:** {title}"
|
||||
created: "**Creado:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Base de données des sessions indisponible."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Aucune session nommée trouvée.\nUtilisez `/title Ma session` pour nommer la session actuelle, puis `/resume Ma session` pour y revenir plus tard."
|
||||
list_header: "📋 **Sessions nommées**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **État de Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID de session :** `{session_id}`"
|
||||
title: "**Titre :** {title}"
|
||||
created: "**Créé :** {timestamp}"
|
||||
|
||||
@@ -223,6 +223,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Níl bunachar sonraí na seisiún ar fáil."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Níor aimsíodh aon seisiún ainmnithe.\nÚsáid `/title M'Ainm Seisiúin` chun do sheisiún reatha a ainmniú, ansin `/resume M'Ainm Seisiúin` chun filleadh air níos déanaí."
|
||||
list_header: "📋 **Seisiúin Ainmnithe**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -255,6 +263,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Stádas Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID Seisiúin:** `{session_id}`"
|
||||
title: "**Teideal:** {title}"
|
||||
created: "**Cruthaithe:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "A munkamenet-adatbázis nem érhető el."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Nem található elnevezett munkamenet.\nHasználd a `/title Saját munkamenet` parancsot a jelenlegi munkamenet elnevezéséhez, majd a `/resume Saját munkamenet` paranccsal térhetsz vissza hozzá."
|
||||
list_header: "📋 **Elnevezett munkamenetek**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway állapot**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Munkamenet-azonosító:** `{session_id}`"
|
||||
title: "**Cím:** {title}"
|
||||
created: "**Létrehozva:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Database delle sessioni non disponibile."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Nessuna sessione con nome trovata.\nUsa `/title My Session` per dare un nome alla sessione attuale, poi `/resume My Session` per tornare a essa in seguito."
|
||||
list_header: "📋 **Sessioni con nome**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Stato del Gateway Hermes**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID sessione:** `{session_id}`"
|
||||
title: "**Titolo:** {title}"
|
||||
created: "**Creata:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "セッションデータベースは利用できません。"
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "名前付きセッションが見つかりません。\n`/title セッション名` で現在のセッションに名前を付けると、後で `/resume セッション名` で戻れます。"
|
||||
list_header: "📋 **名前付きセッション**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes ゲートウェイ状態**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**セッション ID:** `{session_id}`"
|
||||
title: "**タイトル:** {title}"
|
||||
created: "**作成日時:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "세션 데이터베이스를 사용할 수 없습니다."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "이름이 지정된 세션이 없습니다.\n현재 세션에 이름을 지정하려면 `/title 내 세션`을 사용하고, 나중에 `/resume 내 세션`으로 돌아오세요."
|
||||
list_header: "📋 **이름이 지정된 세션**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes 게이트웨이 상태**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**세션 ID:** `{session_id}`"
|
||||
title: "**제목:** {title}"
|
||||
created: "**생성됨:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Base de dados de sessões indisponível."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Não foram encontradas sessões com nome.\nUsa `/title A minha sessão` para nomear a sessão atual e depois `/resume A minha sessão` para voltar a ela."
|
||||
list_header: "📋 **Sessões com nome**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Estado do Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID da sessão:** `{session_id}`"
|
||||
title: "**Título:** {title}"
|
||||
created: "**Criada:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "База данных сеансов недоступна."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Именованных сеансов не найдено.\nИспользуйте `/title Мой сеанс`, чтобы назвать текущий сеанс, затем `/resume Мой сеанс`, чтобы вернуться к нему позже."
|
||||
list_header: "📋 **Именованные сеансы**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Состояние Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID сеанса:** `{session_id}`"
|
||||
title: "**Название:** {title}"
|
||||
created: "**Создано:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "Oturum veritabanı kullanılamıyor."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Adlandırılmış oturum bulunamadı.\nMevcut oturumu adlandırmak için `/title Oturumum`, daha sonra geri dönmek için `/resume Oturumum` kullanın."
|
||||
list_header: "📋 **Adlandırılmış Oturumlar**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes Gateway Durumu**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**Oturum kimliği:** `{session_id}`"
|
||||
title: "**Başlık:** {title}"
|
||||
created: "**Oluşturuldu:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "База даних сеансів недоступна."
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "Іменованих сеансів не знайдено.\nВикористайте `/title Мій сеанс`, щоб назвати поточний сеанс, потім `/resume Мій сеанс`, щоб повернутися до нього."
|
||||
list_header: "📋 **Іменовані сеанси**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Стан Hermes Gateway**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**ID сесії:** `{session_id}`"
|
||||
title: "**Назва:** {title}"
|
||||
created: "**Створено:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "工作階段資料庫無法使用。"
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "找不到已命名的工作階段。\n使用 `/title 我的工作階段` 為目前工作階段命名,然後使用 `/resume 我的工作階段` 返回。"
|
||||
list_header: "📋 **已命名工作階段**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes 閘道狀態**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**工作階段 ID:** `{session_id}`"
|
||||
title: "**標題:** {title}"
|
||||
created: "**建立時間:** {timestamp}"
|
||||
|
||||
@@ -219,6 +219,14 @@ gateway:
|
||||
|
||||
resume:
|
||||
db_unavailable: "会话数据库不可用。"
|
||||
parse_error: "⚠️ Could not parse `/resume` arguments: {error}.
|
||||
Use quotes around titles with spaces, for example: `/resume \"Project A Plan\"`."
|
||||
matrix_no_named_sessions: "No named sessions found for this Matrix room.
|
||||
Use `/title My Session` to name the current room session, `/resume --all` to list all Matrix sessions, or `/resume --cross-room <session name>` to explicitly cross room boundaries."
|
||||
matrix_blocked_no_origin: "⚠️ Matrix /resume blocked: this named session has no recorded room origin, so Hermes will not resume it inside the current room by default. Use `/resume --cross-room {name}` if you intentionally want to cross room boundaries."
|
||||
matrix_blocked_other_room: "⚠️ Matrix /resume blocked: that session belongs to a different Matrix room ({room}). Use `/resume --cross-room {name}` if you intentionally want to resume it here."
|
||||
matrix_cross_room_success: "⚠️ Cross-room resume: resumed **{title}** inside Matrix room **{room}**.
|
||||
Future messages in this room will use that transcript until `/reset` or another `/resume`.{msg_part}"
|
||||
no_named_sessions: "未找到已命名的会话。\n使用 `/title 我的会话` 为当前会话命名,然后用 `/resume 我的会话` 返回。"
|
||||
list_header: "📋 **已命名会话**\n"
|
||||
list_item: "• **{title}**{preview_part}"
|
||||
@@ -251,6 +259,12 @@ gateway:
|
||||
|
||||
status:
|
||||
header: "📊 **Hermes 网关状态**"
|
||||
matrix_scope_header: "**Matrix scope:**"
|
||||
matrix_scope_room: " room: {room}"
|
||||
matrix_scope_room_id: " room_id: {room_id}"
|
||||
matrix_scope_thread: " thread_id: {thread_id}"
|
||||
matrix_scope_mode: " session_scope: {scope}"
|
||||
matrix_scope_key: " session_key: {session_key}"
|
||||
session_id: "**会话 ID:** `{session_id}`"
|
||||
title: "**标题:** {title}"
|
||||
created: "**创建时间:** {timestamp}"
|
||||
|
||||
+59
-13
@@ -892,6 +892,42 @@ function Test-Node {
|
||||
return $true
|
||||
}
|
||||
|
||||
function Update-ProcessPathForPackages {
|
||||
# Make freshly-installed shims (rg.exe, ffmpeg.exe) visible to Get-Command in
|
||||
# THIS process without spawning a new shell, by folding the persisted
|
||||
# User+Machine hives plus winget's alias-shim directory into $env:Path.
|
||||
# Called after every package-manager attempt (winget/choco/scoop): previously
|
||||
# PATH was only refreshed inside the winget branch, so a successful
|
||||
# choco/scoop fallback -- or any install on a box without winget -- could be
|
||||
# misreported as "not installed".
|
||||
#
|
||||
# MERGE rather than overwrite: start from the existing process PATH so any
|
||||
# process-only entries added earlier in this installer run survive, then
|
||||
# APPEND hive/winget-Links entries not already present (case-insensitive,
|
||||
# order-preserving dedupe). A wholesale replace would silently drop those
|
||||
# process-only entries.
|
||||
$candidates = @()
|
||||
$candidates += $env:Path
|
||||
$candidates += [Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$candidates += [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
|
||||
if (Test-Path $wingetLinks) {
|
||||
$candidates += $wingetLinks
|
||||
}
|
||||
$seen = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)
|
||||
$ordered = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($chunk in $candidates) {
|
||||
if ([string]::IsNullOrEmpty($chunk)) { continue }
|
||||
foreach ($entry in $chunk.Split(';')) {
|
||||
$trimmed = $entry.Trim()
|
||||
if ($trimmed -and $seen.Add($trimmed)) {
|
||||
$ordered.Add($trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
$env:Path = [string]::Join(';', $ordered)
|
||||
}
|
||||
|
||||
function Install-SystemPackages {
|
||||
$script:HasRipgrep = $false
|
||||
$script:HasFfmpeg = $false
|
||||
@@ -961,25 +997,33 @@ function Install-SystemPackages {
|
||||
try {
|
||||
$output = winget install --exact --id $pkg --source winget --silent `
|
||||
--accept-package-agreements --accept-source-agreements 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
$output | Out-File -FilePath $log -Encoding utf8
|
||||
"winget exit: $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit: $code" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
# 0x8A15002B (-1978335189) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE.
|
||||
# winget treats `install` on a package it already has registered as
|
||||
# an *upgrade*, finds no newer version, and bails with this code --
|
||||
# even when the binary is gone from disk/PATH (stale registration,
|
||||
# files removed outside winget, or a missing alias shim). We KNOW the
|
||||
# command was missing (that's why we're here), so a plain install
|
||||
# dead-ends forever. Force a reinstall to repair the registration so
|
||||
# the shim reappears.
|
||||
if ($code -eq -1978335189) {
|
||||
"-> already-installed/no-upgrade; retrying with --force" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
$output = winget install --exact --id $pkg --source winget --silent --force `
|
||||
--accept-package-agreements --accept-source-agreements 2>&1
|
||||
$output | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit (force): $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
}
|
||||
} catch {
|
||||
$_ | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
"winget exit: <exception>" | Out-File -FilePath $log -Encoding utf8 -Append
|
||||
}
|
||||
}
|
||||
# Refresh PATH from both env-var hives AND winget's alias shim directory.
|
||||
# winget exposes packages via "command line aliases" in %LOCALAPPDATA%\
|
||||
# Microsoft\WinGet\Links, which is added to PATH by the AppExecutionAlias
|
||||
# machinery only in *newly-spawned* shells -- not the current process.
|
||||
# Without this addition, Get-Command rg below would falsely return null
|
||||
# immediately after a successful install.
|
||||
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
|
||||
$envPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if (Test-Path $wingetLinks) {
|
||||
$envPath = "$envPath;$wingetLinks"
|
||||
}
|
||||
$env:Path = $envPath
|
||||
# Refresh PATH so packages winget exposed via "command line aliases" in
|
||||
# %LOCALAPPDATA%\Microsoft\WinGet\Links (added to PATH only in
|
||||
# newly-spawned shells, not this process) are visible to Get-Command below.
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed"
|
||||
$script:HasRipgrep = $true
|
||||
@@ -1005,6 +1049,7 @@ function Install-SystemPackages {
|
||||
foreach ($pkg in $chocoPkgs) {
|
||||
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
|
||||
}
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed via chocolatey"
|
||||
$script:HasRipgrep = $true
|
||||
@@ -1023,6 +1068,7 @@ function Install-SystemPackages {
|
||||
foreach ($pkg in $scoopPkgs) {
|
||||
try { scoop install $pkg 2>&1 | Out-Null } catch { }
|
||||
}
|
||||
Update-ProcessPathForPackages
|
||||
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
|
||||
Write-Success "ripgrep installed via scoop"
|
||||
$script:HasRipgrep = $true
|
||||
|
||||
@@ -75,7 +75,10 @@ AUTHOR_MAP = {
|
||||
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"mvanhorn@MacBook-Pro.local": "mvanhorn",
|
||||
"470766206@qq.com": "youjunxiaji",
|
||||
"mharris@parallel.ai": "NormallyGaussian",
|
||||
"roger@roger.local": "mollusk",
|
||||
"ted.malone@outlook.com": "temalo",
|
||||
"adityamalik2833@gmail.com": "alarcritty",
|
||||
"islam666@users.noreply.github.com": "islam666",
|
||||
@@ -1513,6 +1516,7 @@ AUTHOR_MAP = {
|
||||
"josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge)
|
||||
"andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design)
|
||||
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
|
||||
"desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ import { Boom } from '@hapi/boom';
|
||||
import pino from 'pino';
|
||||
import path from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
@@ -45,9 +46,28 @@ const WHATSAPP_DEBUG =
|
||||
|
||||
const PORT = parseInt(getArg('port', '3000'), 10);
|
||||
const SESSION_DIR = getArg('session', path.join(process.env.HOME || '~', '.hermes', 'whatsapp', 'session'));
|
||||
const IMAGE_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
// Cache directories: the Python gateway passes the profile-aware paths via
|
||||
// env (HERMES_HOME-aware, new cache/ layout). Fall back to the legacy
|
||||
// hardcoded locations for bridges launched outside the gateway.
|
||||
const IMAGE_CACHE_DIR = process.env.HERMES_IMAGE_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'image_cache');
|
||||
const DOCUMENT_CACHE_DIR = process.env.HERMES_DOCUMENT_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'document_cache');
|
||||
const AUDIO_CACHE_DIR = process.env.HERMES_AUDIO_CACHE_DIR
|
||||
|| path.join(process.env.HOME || '~', '.hermes', 'audio_cache');
|
||||
|
||||
// Self-hash of this script file. Reported in /health so the Python gateway
|
||||
// can detect a running bridge that predates the current bridge.js and
|
||||
// restart it instead of silently reusing stale code (stale-bridge trap:
|
||||
// `hermes update` updates bridge.js on disk but a long-lived bridge process
|
||||
// keeps serving the old behavior forever).
|
||||
let SCRIPT_HASH = '';
|
||||
try {
|
||||
SCRIPT_HASH = createHash('sha256')
|
||||
.update(readFileSync(fileURLToPath(import.meta.url)))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
} catch {}
|
||||
const PAIR_ONLY = args.includes('--pair-only');
|
||||
const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat"
|
||||
const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || '');
|
||||
@@ -700,6 +720,7 @@ app.get('/health', (req, res) => {
|
||||
status: connectionState,
|
||||
queueLength: messageQueue.length,
|
||||
uptime: process.uptime(),
|
||||
scriptHash: SCRIPT_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Tests for agent.coding_context — RuntimeMode seam, resolver, toolset, git probe."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import coding_context as cc
|
||||
|
||||
|
||||
def _git_init(path):
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
}
|
||||
for args in (
|
||||
["init", "-q", "-b", "main"],
|
||||
["commit", "-q", "--allow-empty", "-m", "init commit"],
|
||||
):
|
||||
subprocess.run(["git", "-C", str(path), *args], check=True, env={**env, "HOME": str(path)})
|
||||
|
||||
|
||||
# ── resolver ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsCodingContext:
|
||||
def test_off_never_activates(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "off"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
|
||||
def test_on_forces_even_without_git(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.is_coding_context(platform="telegram", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_auto_requires_git_repo(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
_git_init(tmp_path)
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_auto_skips_messaging_surfaces(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="discord", cwd=tmp_path, config=cfg) is False
|
||||
assert cc.is_coding_context(platform="tui", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_default_mode_is_auto(self, tmp_path):
|
||||
# Unknown/missing value normalizes to auto.
|
||||
_git_init(tmp_path)
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config={}) is True
|
||||
|
||||
|
||||
# ── toolset substitution ────────────────────────────────────────────────────
|
||||
|
||||
class TestCodingSelection:
|
||||
def test_selects_coding_under_focus(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "focus"}}
|
||||
out = cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg)
|
||||
assert out is not None
|
||||
assert out[0] == cc.CODING_TOOLSET
|
||||
|
||||
def test_auto_is_prompt_only(self, tmp_path):
|
||||
# Default posture must never override the user's configured toolsets —
|
||||
# off-by-default toolsets are already off, and explicit opt-ins
|
||||
# (image-gen, spotify, …) survive entering a code workspace.
|
||||
_git_init(tmp_path)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
# …while the prompt posture is still active.
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_on_is_prompt_only(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_focus_requires_workspace(self, tmp_path):
|
||||
# focus inherits auto's detection gate — bare dir stays general.
|
||||
cfg = {"agent": {"coding_context": "focus"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
|
||||
def test_none_when_inactive(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "off"}}
|
||||
assert cc.coding_selection(platform="cli", cwd=tmp_path, config=cfg) is None
|
||||
|
||||
def test_coding_toolset_is_registered(self):
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
tools = resolve_toolset(cc.CODING_TOOLSET)
|
||||
# Coding essentials present…
|
||||
for t in ("read_file", "write_file", "patch", "search_files", "terminal", "todo"):
|
||||
assert t in tools
|
||||
# …and the noise is gone.
|
||||
for t in ("send_message", "text_to_speech", "image_generate", "computer_use"):
|
||||
assert t not in tools
|
||||
|
||||
|
||||
# ── git/workspace probe ─────────────────────────────────────────────────────
|
||||
|
||||
class TestWorkspaceBlock:
|
||||
def test_empty_outside_repo(self, tmp_path):
|
||||
assert cc.build_coding_workspace_block(tmp_path) == ""
|
||||
|
||||
def test_reports_branch_and_clean_status(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Workspace" in block
|
||||
assert f"Root: {tmp_path.resolve()}" in block or "Root:" in block
|
||||
assert "Branch: main" in block
|
||||
assert "Status: clean" in block
|
||||
assert "init commit" in block
|
||||
|
||||
def test_reports_dirty_counts(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "untracked.txt").write_text("hi")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "untracked" in block
|
||||
assert "clean" not in block.split("Status:")[1].splitlines()[0]
|
||||
|
||||
|
||||
# ── project facts (verify-loop detection) ───────────────────────────────────
|
||||
|
||||
class TestProjectFacts:
|
||||
def test_package_json_scripts_surface_verify_commands(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "package.json").write_text(
|
||||
json.dumps({"scripts": {"test": "vitest", "lint": "eslint .", "dev": "vite"}})
|
||||
)
|
||||
(tmp_path / "pnpm-lock.yaml").write_text("")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Project: package.json (pnpm)" in block
|
||||
assert "pnpm run test" in block and "pnpm run lint" in block
|
||||
# Non-verify scripts (dev servers, …) stay out of the snapshot.
|
||||
assert "run dev" not in block
|
||||
|
||||
def test_pytest_config_and_run_tests_script(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n")
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "run_tests.sh").write_text("#!/bin/sh\n")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "scripts/run_tests.sh" in block
|
||||
assert "pytest" in block.split("Verify:")[1]
|
||||
|
||||
def test_makefile_verify_targets_only(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "Makefile").write_text("test:\n\tgo test ./...\n\ndeploy:\n\t./deploy.sh\n")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "make test" in block
|
||||
assert "make deploy" not in block
|
||||
|
||||
def test_context_files_listed(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "AGENTS.md").write_text("# rules")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Context files: AGENTS.md" in block
|
||||
|
||||
def test_marker_only_project_gets_snapshot_without_git(self, tmp_path):
|
||||
# A non-git project (manifest only) still gets a workspace snapshot —
|
||||
# just without the git lines.
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert f"Root: {tmp_path.resolve()}" in block
|
||||
assert "package.json" in block
|
||||
assert "Branch:" not in block and "Status:" not in block
|
||||
|
||||
def test_malformed_package_json_is_ignored(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "package.json").write_text("{not json")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Project: package.json" in block
|
||||
assert "Verify:" not in block
|
||||
|
||||
|
||||
# ── $HOME dotfiles guard ────────────────────────────────────────────────────
|
||||
|
||||
class TestHomeDotfilesGuard:
|
||||
def test_dotfiles_repo_at_home_is_not_coding(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_git_init(home)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
|
||||
# …and a plain subdirectory of the dotfiles repo stays general too.
|
||||
docs = home / "Documents"
|
||||
docs.mkdir()
|
||||
assert cc.is_coding_context(platform="cli", cwd=docs, config=cfg) is False
|
||||
|
||||
def test_marker_at_home_is_not_a_project_signal(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
(home / "Makefile").write_text("all:\n")
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is False
|
||||
|
||||
def test_real_project_under_dotfiles_home_still_detects(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
_git_init(home)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
proj = home / "www" / "app"
|
||||
proj.mkdir(parents=True)
|
||||
(proj / "package.json").write_text("{}")
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=proj, config=cfg) is True
|
||||
|
||||
def test_on_mode_bypasses_the_guard(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
cfg = {"agent": {"coding_context": "on"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=home, config=cfg) is True
|
||||
|
||||
|
||||
# ── prompt assembly integration ─────────────────────────────────────────────
|
||||
|
||||
class TestStatusParsing:
|
||||
def test_parse_status_counts_and_branch(self):
|
||||
porcelain = (
|
||||
"# branch.head feature\n"
|
||||
"# branch.upstream origin/feature\n"
|
||||
"# branch.ab +2 -1\n"
|
||||
"1 M. N... 100644 100644 100644 aaa bbb staged.py\n"
|
||||
"1 .M N... 100644 100644 100644 ccc ddd modified.py\n"
|
||||
"? new.py\n"
|
||||
"u UU N... 1 2 3 abc def conflict.py\n"
|
||||
)
|
||||
branch, counts = cc._parse_status(porcelain)
|
||||
assert branch["head"] == "feature"
|
||||
assert branch["upstream"] == "origin/feature"
|
||||
assert branch["ahead"] == "2" and branch["behind"] == "1"
|
||||
assert counts["staged"] == 1
|
||||
assert counts["modified"] == 1
|
||||
assert counts["untracked"] == 1
|
||||
assert counts["conflicts"] == 1
|
||||
|
||||
|
||||
# ── RuntimeMode seam ────────────────────────────────────────────────────────
|
||||
|
||||
class TestRuntimeMode:
|
||||
def test_resolves_coding_in_repo(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
assert mode.is_coding is True
|
||||
assert mode.kind == "coding"
|
||||
assert mode.profile is cc.CODING_PROFILE
|
||||
|
||||
def test_resolves_general_outside_workspace(self, tmp_path):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
assert mode.is_coding is False
|
||||
assert mode.kind == "general"
|
||||
# General posture pins no toolset and injects no blocks.
|
||||
assert mode.toolset_selection() is None
|
||||
assert mode.system_blocks() == []
|
||||
|
||||
def test_is_frozen(self, tmp_path):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
with pytest.raises(Exception):
|
||||
mode.profile = cc.CODING_PROFILE # type: ignore[misc]
|
||||
|
||||
def test_system_blocks_include_brief_and_workspace(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
|
||||
blocks = mode.system_blocks()
|
||||
assert any("coding agent" in b for b in blocks)
|
||||
assert any("Workspace" in b for b in blocks)
|
||||
|
||||
def test_toolset_selection_gated_on_focus(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})
|
||||
sel = focus.toolset_selection()
|
||||
assert sel and sel[0] == cc.CODING_TOOLSET
|
||||
# auto/on resolve the coding profile but stay prompt-only.
|
||||
for raw in ("auto", "on"):
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}})
|
||||
assert mode.is_coding is True
|
||||
assert mode.toolset_selection() is None
|
||||
|
||||
|
||||
# ── edit-format steering (per-model harness tuning) ──────────────────────────
|
||||
|
||||
class TestEditFormatSteering:
|
||||
def test_family_detection(self):
|
||||
assert cc._model_family("openai/gpt-5.4") == "patch"
|
||||
assert cc._model_family("openai/codex-mini") == "patch"
|
||||
assert cc._model_family("anthropic/claude-opus-4.8") == "replace"
|
||||
assert cc._model_family("anthropic/claude-sonnet-4") == "replace"
|
||||
# Gemini + open-weight coding models (RL'd on str_replace-style
|
||||
# editors) steer to replace, not neutral.
|
||||
for m in (
|
||||
"google/gemini-3-pro", "deepseek-v3.2", "qwen3-coder",
|
||||
"moonshot/kimi-k2", "zai/glm-4.6", "nousresearch/hermes-4-405b",
|
||||
):
|
||||
assert cc._model_family(m) == "replace"
|
||||
# Unknown family and no model both fall through to neutral wording.
|
||||
assert cc._model_family("acme/foo-1") is None
|
||||
assert cc._model_family(None) is None
|
||||
assert cc._model_family("") is None
|
||||
|
||||
def test_openai_family_gets_v4a_nudge(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}}, model="openai/gpt-5.4",
|
||||
)
|
||||
brief = mode.system_blocks()[0]
|
||||
assert "mode='patch'" in brief
|
||||
assert "V4A" in brief
|
||||
assert "write_file" in brief # new files authored, not patched
|
||||
|
||||
def test_anthropic_family_gets_replace_nudge(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}},
|
||||
model="anthropic/claude-opus-4.8",
|
||||
)
|
||||
brief = mode.system_blocks()[0]
|
||||
assert "mode='replace'" in brief
|
||||
assert "write_file" in brief # new files authored, not patched
|
||||
|
||||
def test_unknown_model_keeps_neutral_brief(self, tmp_path):
|
||||
# No edit-format line appended — brief equals the bare profile guidance.
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}}, model="acme/foo-1",
|
||||
)
|
||||
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
|
||||
|
||||
def test_no_model_keeps_neutral_brief(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "on"}},
|
||||
)
|
||||
assert mode.system_blocks()[0] == cc.CODING_AGENT_GUIDANCE
|
||||
|
||||
def test_general_posture_emits_nothing_regardless_of_model(self, tmp_path):
|
||||
# Edit steering only fires inside the coding posture.
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="telegram", cwd=tmp_path, config={}, model="openai/gpt-5.4",
|
||||
)
|
||||
assert mode.system_blocks() == []
|
||||
|
||||
|
||||
# ── profile registry ────────────────────────────────────────────────────────
|
||||
|
||||
class TestProfiles:
|
||||
def test_registered_profiles(self):
|
||||
assert cc.get_profile("coding") is cc.CODING_PROFILE
|
||||
assert cc.get_profile("general") is cc.GENERAL_PROFILE
|
||||
|
||||
def test_unknown_profile_falls_back_to_general(self):
|
||||
assert cc.get_profile("nonsense") is cc.GENERAL_PROFILE
|
||||
|
||||
def test_coding_profile_shape(self):
|
||||
# The coding profile declares the seams other domains read.
|
||||
assert cc.CODING_PROFILE.toolset == cc.CODING_TOOLSET
|
||||
assert cc.CODING_PROFILE.guidance
|
||||
assert cc.CODING_PROFILE.model_hint == "coding"
|
||||
# General is inert.
|
||||
assert cc.GENERAL_PROFILE.toolset is None
|
||||
assert cc.GENERAL_PROFILE.guidance == ""
|
||||
|
||||
def test_skill_pruning_scoped_to_coding_posture(self, tmp_path):
|
||||
# Coding posture hides clearly-non-coding categories; coding-adjacent
|
||||
# ones stay visible (deny-list semantics).
|
||||
_git_init(tmp_path)
|
||||
coding = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
hidden = coding.hidden_skill_categories()
|
||||
assert "social-media" in hidden and "smart-home" in hidden
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in hidden
|
||||
# General posture hides nothing.
|
||||
general = cc.resolve_runtime_mode(
|
||||
platform="telegram", cwd=tmp_path, config={}
|
||||
)
|
||||
assert general.hidden_skill_categories() == frozenset()
|
||||
|
||||
|
||||
# ── detection signals ───────────────────────────────────────────────────────
|
||||
|
||||
class TestDetection:
|
||||
@pytest.mark.parametrize("marker", ["pyproject.toml", "package.json", "go.mod", "AGENTS.md"])
|
||||
def test_project_manifest_triggers_without_git(self, tmp_path, marker):
|
||||
(tmp_path / marker).write_text("x")
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is True
|
||||
|
||||
def test_marker_in_parent_counts_from_subdir(self, tmp_path):
|
||||
(tmp_path / "pyproject.toml").write_text("x")
|
||||
sub = tmp_path / "src" / "pkg"
|
||||
sub.mkdir(parents=True)
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=sub, config=cfg) is True
|
||||
|
||||
def test_bare_dir_is_not_coding(self, tmp_path):
|
||||
cfg = {"agent": {"coding_context": "auto"}}
|
||||
assert cc.is_coding_context(platform="cli", cwd=tmp_path, config=cfg) is False
|
||||
@@ -276,6 +276,42 @@ class TestBuildSkillsSystemPrompt:
|
||||
# "search" should appear only once per category
|
||||
assert result.count("- search") == 1
|
||||
|
||||
def test_hidden_categories_pruned_with_note(self, monkeypatch, tmp_path):
|
||||
"""Posture-driven pruning drops whole categories and discloses it."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
for cat, name in (("social-media", "tweet-stuff"), ("github", "pr-review")):
|
||||
d = tmp_path / "skills" / cat / name
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Does {name} things\n---\n"
|
||||
)
|
||||
|
||||
result = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "pr-review" in result
|
||||
assert "tweet-stuff" not in result
|
||||
# Disclosure note so the model knows the full catalog exists.
|
||||
assert "skills_list" in result
|
||||
|
||||
def test_hidden_categories_prune_nested_and_miss_cache_separately(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
d = tmp_path / "skills" / "social-media" / "twitter" / "thread-writer"
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\nname: thread-writer\ndescription: Write threads\n---\n"
|
||||
)
|
||||
# Nested category ("social-media/twitter") pruned via its parent.
|
||||
pruned = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "thread-writer" not in pruned
|
||||
# Unfiltered call must not be served from the filtered cache entry.
|
||||
full = build_skills_system_prompt()
|
||||
assert "thread-writer" in full
|
||||
|
||||
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
|
||||
"""Skills with platforms: [macos] should not appear on Linux."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
@@ -55,3 +55,44 @@ class TestContextFileCwd:
|
||||
def test_configured_dir_when_terminal_cwd_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
assert _captured_context_cwd(_make_agent()) == tmp_path
|
||||
|
||||
|
||||
def _stable_prompt(agent):
|
||||
with (
|
||||
patch("run_agent.load_soul_md", return_value=""),
|
||||
patch("run_agent.build_nous_subscription_prompt", return_value=""),
|
||||
patch("run_agent.build_environment_hints", return_value=""),
|
||||
patch("run_agent.build_context_files_prompt", return_value=""),
|
||||
):
|
||||
return build_system_prompt_parts(agent)["stable"]
|
||||
|
||||
|
||||
class TestCodingContextBlock:
|
||||
def test_injected_when_active(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
||||
stable = _stable_prompt(agent)
|
||||
assert "coding agent" in stable
|
||||
assert "Workspace" in stable
|
||||
|
||||
def test_absent_when_off(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=["read_file"], platform="cli")
|
||||
# Drive the real path: force the resolved mode to "off" via config.
|
||||
with patch("agent.coding_context._coding_mode", return_value="off"):
|
||||
stable = _stable_prompt(agent)
|
||||
assert "coding agent" not in stable
|
||||
|
||||
def test_absent_without_tools(self, monkeypatch, tmp_path):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(["git", "-C", str(tmp_path), "init", "-q"], check=True)
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
|
||||
agent = _make_agent(valid_tool_names=[], platform="cli")
|
||||
assert "coding agent" not in _stable_prompt(agent)
|
||||
|
||||
@@ -676,3 +676,54 @@ class TestStatusBarWidthSource:
|
||||
mock_get_app.assert_not_called()
|
||||
mock_shutil.assert_not_called()
|
||||
assert len(text) > 0
|
||||
|
||||
|
||||
class TestIdleSinceLastTurn:
|
||||
"""Time-since-last-final-agent-response read-out on the status bar."""
|
||||
|
||||
def test_hidden_before_first_turn(self):
|
||||
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
|
||||
|
||||
def test_hidden_while_turn_is_live(self):
|
||||
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
|
||||
|
||||
def test_shows_compact_idle_time_after_turn(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
|
||||
assert label.startswith("✓ ")
|
||||
assert label == "✓ 42s"
|
||||
|
||||
def test_scales_to_minutes(self):
|
||||
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
|
||||
assert label == "✓ 3m"
|
||||
|
||||
def test_snapshot_carries_idle_since(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 5.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"].startswith("✓ ")
|
||||
|
||||
def test_snapshot_idle_empty_during_live_turn(self):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._last_turn_finished_at = time.time() - 10
|
||||
cli_obj._prompt_start_time = time.time()
|
||||
cli_obj._prompt_duration = 0.0
|
||||
snapshot = cli_obj._get_status_bar_snapshot()
|
||||
assert snapshot["idle_since"] == ""
|
||||
|
||||
def test_wide_status_bar_text_includes_idle(self):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
completion_tokens=2_220,
|
||||
total_tokens=12_450,
|
||||
api_calls=7,
|
||||
context_tokens=12_450,
|
||||
context_length=200_000,
|
||||
)
|
||||
cli_obj._last_turn_finished_at = time.time() - 42
|
||||
cli_obj._prompt_start_time = None
|
||||
cli_obj._prompt_duration = 7.0
|
||||
text = cli_obj._build_status_bar_text(width=160)
|
||||
assert "✓ 42s" in text
|
||||
|
||||
+1506
-24
File diff suppressed because it is too large
Load Diff
@@ -28,13 +28,38 @@ def _stub_mautrix():
|
||||
sys.modules.setdefault(sub, types.ModuleType(sub))
|
||||
sys.modules.setdefault("mautrix", stub)
|
||||
m = sys.modules["mautrix.types"]
|
||||
for attr in (
|
||||
"ContentURI", "EventID", "EventType", "PaginationDirection",
|
||||
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
|
||||
"TrustState", "UserID",
|
||||
):
|
||||
if not hasattr(m, attr):
|
||||
setattr(m, attr, str)
|
||||
|
||||
class EventType:
|
||||
ROOM_MESSAGE = "m.room.message"
|
||||
REACTION = "m.reaction"
|
||||
ROOM_ENCRYPTED = "m.room.encrypted"
|
||||
ROOM_NAME = "m.room.name"
|
||||
|
||||
class PaginationDirection:
|
||||
BACKWARD = "b"
|
||||
FORWARD = "f"
|
||||
|
||||
class PresenceState:
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
class RoomCreatePreset:
|
||||
PRIVATE = "private_chat"
|
||||
PUBLIC = "public_chat"
|
||||
TRUSTED_PRIVATE = "trusted_private_chat"
|
||||
|
||||
class TrustState:
|
||||
UNVERIFIED = 0
|
||||
VERIFIED = 1
|
||||
|
||||
for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
|
||||
setattr(m, attr, str)
|
||||
m.EventType = EventType
|
||||
m.PaginationDirection = PaginationDirection
|
||||
m.PresenceState = PresenceState
|
||||
m.RoomCreatePreset = RoomCreatePreset
|
||||
m.TrustState = TrustState
|
||||
|
||||
|
||||
_stub_mautrix()
|
||||
|
||||
@@ -27,9 +27,9 @@ class TestMatrixExecApprovalReactions:
|
||||
assert result.success is True
|
||||
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||
assert adapter._send_reaction.await_count == 2
|
||||
assert adapter._send_reaction.await_count == 3
|
||||
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||
assert emojis == ["✅", "❎"]
|
||||
assert emojis == ["✅", "♾️", "❌"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Matrix Project A / Project B context-isolation regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import (
|
||||
SessionContext,
|
||||
SessionEntry,
|
||||
SessionSource,
|
||||
build_session_context_prompt,
|
||||
build_session_key,
|
||||
)
|
||||
|
||||
PROJECT_A_ROOM_ID = "!projectA:example.org"
|
||||
PROJECT_B_ROOM_ID = "!projectB:example.org"
|
||||
PROJECT_A_NAME = "Project - Project A"
|
||||
PROJECT_B_NAME = "Project - Project B"
|
||||
PROJECT_A_TOPIC = "Architecture and deploy plan for Project A"
|
||||
PROJECT_B_TOPIC = "Migration and branch plan for Project B"
|
||||
PROJECT_A_ALIAS = "#project-a:example.org"
|
||||
PROJECT_B_ALIAS = "#project-b:example.org"
|
||||
SENDER = "@alice:example.org"
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
token="test-token",
|
||||
extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"},
|
||||
)
|
||||
)
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._require_mention = False
|
||||
adapter._auto_thread = False
|
||||
adapter._matrix_session_scope = "room"
|
||||
adapter._text_batch_delay_seconds = 0
|
||||
adapter._background_read_receipt = MagicMock()
|
||||
adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
adapter._client = _FakeMatrixClient()
|
||||
return adapter
|
||||
|
||||
|
||||
class _FakeMatrixClient:
|
||||
def __init__(self):
|
||||
self.state_store = MagicMock()
|
||||
self.state_store.get_members = AsyncMock(return_value=["@bot:example.org", SENDER])
|
||||
|
||||
async def get_state_event(self, room_id, event_type):
|
||||
rid = str(room_id)
|
||||
state = {
|
||||
PROJECT_A_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_A_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_A_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_A_ALIAS}},
|
||||
},
|
||||
PROJECT_B_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_B_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_B_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_B_ALIAS}},
|
||||
},
|
||||
}
|
||||
value = state.get(rid, {}).get(str(event_type))
|
||||
if value is None:
|
||||
raise KeyError((rid, event_type))
|
||||
return value
|
||||
|
||||
|
||||
async def _source_for(adapter, room_id: str, event_id: str = "$event"):
|
||||
ctx = await adapter._resolve_message_context(
|
||||
room_id=room_id,
|
||||
sender=SENDER,
|
||||
event_id=event_id,
|
||||
body="What is next?",
|
||||
source_content={"body": "What is next?"},
|
||||
relates_to={},
|
||||
)
|
||||
assert ctx is not None
|
||||
return ctx[-1]
|
||||
|
||||
|
||||
def _matrix_event(room_id: str, event_id: str, body: str = "What is next?"):
|
||||
event = MagicMock()
|
||||
event.room_id = room_id
|
||||
event.sender = SENDER
|
||||
event.event_id = event_id
|
||||
event.timestamp = int(time.time() * 1000)
|
||||
event.server_timestamp = event.timestamp
|
||||
event.content = {"msgtype": "m.text", "body": body}
|
||||
return event
|
||||
|
||||
|
||||
def _context_for(source: SessionSource) -> SessionContext:
|
||||
return SessionContext(
|
||||
source=source,
|
||||
connected_platforms=[Platform.MATRIX],
|
||||
home_channels={},
|
||||
session_key=build_session_key(source),
|
||||
session_id="session-test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_source_includes_room_name_topic_and_message_id():
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$project-b-msg")
|
||||
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.guild_id == "example.org"
|
||||
assert source.message_id == "$project-b-msg"
|
||||
assert source.parent_chat_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_a_and_project_b_have_distinct_session_keys():
|
||||
adapter = _make_adapter()
|
||||
source_a = await _source_for(adapter, PROJECT_A_ROOM_ID, "$a")
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
assert source_a.chat_id != source_b.chat_id
|
||||
assert source_a.chat_name == PROJECT_A_NAME
|
||||
assert source_b.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(source_a) != build_session_key(source_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_b_prompt_contains_project_b_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
prompt = build_session_context_prompt(_context_for(source_b))
|
||||
|
||||
assert PROJECT_B_NAME in prompt
|
||||
assert PROJECT_B_TOPIC in prompt
|
||||
assert PROJECT_B_ROOM_ID in prompt
|
||||
assert "Matrix room boundary" in prompt
|
||||
assert PROJECT_A_NAME not in prompt
|
||||
assert PROJECT_A_TOPIC not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_sequential_messages():
|
||||
adapter = _make_adapter()
|
||||
adapter._matrix_session_scope = "room"
|
||||
first = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b1")
|
||||
second = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b2")
|
||||
|
||||
assert first.thread_id is None
|
||||
assert second.thread_id is None
|
||||
assert first.chat_name == PROJECT_B_NAME
|
||||
assert second.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(first) == build_session_key(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
|
||||
adapter = _make_adapter()
|
||||
adapter._auto_thread = True
|
||||
adapter._matrix_session_scope = "auto"
|
||||
auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
|
||||
assert auto_source.thread_id == "$auto"
|
||||
|
||||
adapter._matrix_session_scope = "thread"
|
||||
thread_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$thread")
|
||||
assert thread_source.thread_id == "$thread"
|
||||
|
||||
real_thread = await adapter._resolve_message_context(
|
||||
room_id=PROJECT_B_ROOM_ID,
|
||||
sender=SENDER,
|
||||
event_id="$reply",
|
||||
body="thread reply",
|
||||
source_content={"body": "thread reply"},
|
||||
relates_to={"rel_type": "m.thread", "event_id": "$root"},
|
||||
)
|
||||
assert real_thread is not None
|
||||
assert real_thread[-1].thread_id == "$root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_concurrent_messages():
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
async def observe(room_id: str):
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, room_id, f"${room_id}")
|
||||
context = _context_for(source)
|
||||
runner = object.__new__(GatewayRunner)
|
||||
tokens = runner._set_session_env(context)
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
return SimpleNamespace(
|
||||
chat_id=get_session_env("HERMES_SESSION_CHAT_ID"),
|
||||
chat_name=get_session_env("HERMES_SESSION_CHAT_NAME"),
|
||||
session_key=get_session_env("HERMES_SESSION_KEY"),
|
||||
)
|
||||
finally:
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
observed_a, observed_b = await asyncio.gather(
|
||||
observe(PROJECT_A_ROOM_ID),
|
||||
observe(PROJECT_B_ROOM_ID),
|
||||
)
|
||||
|
||||
assert observed_a.chat_id == PROJECT_A_ROOM_ID
|
||||
assert observed_b.chat_id == PROJECT_B_ROOM_ID
|
||||
assert observed_a.chat_name == PROJECT_A_NAME
|
||||
assert observed_b.chat_name == PROJECT_B_NAME
|
||||
assert observed_a.session_key != observed_b.session_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_emits_project_b_metadata_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b"))
|
||||
|
||||
assert len(captured) == 1
|
||||
source = captured[0].source
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.message_id == "$project-b"
|
||||
assert PROJECT_A_NAME not in repr(source.to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_keeps_project_a_and_b_distinct():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_A_ROOM_ID, "$project-a", "A"))
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b", "B"))
|
||||
|
||||
assert [event.source.chat_id for event in captured] == [
|
||||
PROJECT_A_ROOM_ID,
|
||||
PROJECT_B_ROOM_ID,
|
||||
]
|
||||
assert [event.source.chat_name for event in captured] == [
|
||||
PROJECT_A_NAME,
|
||||
PROJECT_B_NAME,
|
||||
]
|
||||
assert build_session_key(captured[0].source) != build_session_key(captured[1].source)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_true_separates_users():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=True) != build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=True,
|
||||
)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_false_shares_room():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=False) == build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_matrix_source(room_id: str, room_name: str, topic: str) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=room_id,
|
||||
chat_name=room_name,
|
||||
chat_type="group",
|
||||
user_id=SENDER,
|
||||
user_name="Alice",
|
||||
chat_topic=topic,
|
||||
)
|
||||
|
||||
|
||||
def _entry(source: SessionSource, session_id: str, title: str | None = None) -> SessionEntry:
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
display_name=title or source.chat_name,
|
||||
platform=Platform.MATRIX,
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(current_source: SessionSource, entries: list[SessionEntry]):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(platforms={Platform.MATRIX: PlatformConfig(enabled=True)})
|
||||
adapter = MagicMock()
|
||||
adapter._matrix_session_scope = "room"
|
||||
runner.adapters = {Platform.MATRIX: adapter}
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store._entries = {entry.session_key: entry for entry in entries}
|
||||
current = next((e for e in entries if e.origin and e.origin.chat_id == current_source.chat_id), entries[0])
|
||||
runner.session_store.get_or_create_session.return_value = current
|
||||
runner.session_store.switch_session.return_value = current
|
||||
runner.session_store.load_transcript.return_value = [{"role": "user", "content": "hello"}]
|
||||
runner._running_agents = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._release_running_agent_state = MagicMock()
|
||||
runner._clear_session_boundary_security_state = MagicMock()
|
||||
runner._evict_cached_agent = MagicMock()
|
||||
runner._queue_depth = MagicMock(return_value=0)
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.list_sessions_rich.return_value = [
|
||||
{"id": entry.session_id, "title": entry.display_name, "preview": ""}
|
||||
for entry in entries
|
||||
]
|
||||
runner._session_db.resolve_resume_session_id.side_effect = lambda sid: sid
|
||||
runner._session_db.get_session_title.side_effect = lambda sid: {
|
||||
entry.session_id: entry.display_name for entry in entries
|
||||
}.get(sid)
|
||||
runner._session_db.get_session.return_value = None
|
||||
return runner
|
||||
|
||||
|
||||
def _event(text: str, source: SessionSource) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=source, message_id="$cmd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_status_reports_current_matrix_room_scope():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [_entry(source_a, "session-a", "Project A Plan"), entry_b])
|
||||
|
||||
result = await runner._handle_status_command(_event("/status", source_b))
|
||||
|
||||
assert "Matrix scope:" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
assert PROJECT_B_ROOM_ID in result
|
||||
assert "session_scope: room" in result
|
||||
session_key = build_session_key(source_b)
|
||||
assert session_key not in result
|
||||
assert session_key[:8] not in result
|
||||
assert "session_key: sha256:" in result
|
||||
assert PROJECT_A_NAME not in result
|
||||
assert PROJECT_A_ROOM_ID not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_does_not_cross_rooms_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project A Plan", source_b))
|
||||
|
||||
assert "blocked" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_allows_same_room_session():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project B Plan", source_b))
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_same_room():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner._session_db.resolve_session_by_title.assert_called_once_with("Project B Plan")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_cross_room_blocked():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project A Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "blocked" in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_malformed_quote_returns_helpful_error():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(source_b, [_entry(source_b, "session-b", "Project B Plan")])
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan', source_b)
|
||||
)
|
||||
|
||||
assert "Could not parse" in result
|
||||
assert "quotes" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_cross_room_requires_explicit_flag_and_warns():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner.session_store.switch_session.return_value = entry_a
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event("/resume --cross-room Project A Plan", source_b)
|
||||
)
|
||||
|
||||
assert "Cross-room resume" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_lists_only_current_room_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume", source_b))
|
||||
|
||||
assert "Project B Plan" in result
|
||||
assert "Project A Plan" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_all_lists_room_names():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume --all", source_b))
|
||||
|
||||
assert "Project A Plan" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
assert "Project B Plan" in result
|
||||
@@ -197,8 +197,10 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "linux")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
@@ -217,6 +219,72 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
assert kwargs["start_new_session"] is True
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
# The watcher must NOT inherit the gateway marker, or the CLI's
|
||||
# self-restart loop guard refuses to run `hermes gateway restart`.
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
|
||||
|
||||
def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
pth_extra = tmp_path / "pywin32_system32"
|
||||
site_packages.mkdir(parents=True)
|
||||
pth_extra.mkdir()
|
||||
(site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8")
|
||||
project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run.sys, "path", ["existing"])
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
monkeypatch.setenv("PYTHONPATH", "already-there")
|
||||
|
||||
gateway_run._ensure_windows_gateway_venv_imports()
|
||||
|
||||
assert gateway_run.sys.path[:2] == [project_root, str(site_packages)]
|
||||
assert str(pth_extra) in gateway_run.sys.path
|
||||
assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve())
|
||||
pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert pythonpath[:3] == [project_root, str(site_packages), "already-there"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
site_packages.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
|
||||
import hermes_cli._subprocess_compat as subprocess_compat
|
||||
|
||||
monkeypatch.setattr(
|
||||
subprocess_compat,
|
||||
"windows_detach_popen_kwargs",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
await runner._launch_detached_restart_command()
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[-3:] == ["hermes", "gateway", "restart"]
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir)
|
||||
assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
# ── Shutdown notification tests ──────────────────────────────────────
|
||||
|
||||
@@ -1488,3 +1488,72 @@ async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert "```bash" not in all_content
|
||||
|
||||
class MultiTerminalCommandAgent:
|
||||
"""Emits several consecutive terminal tool.started events, then a
|
||||
different tool, then terminal again — to exercise header collapsing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
cb = self.tool_progress_callback
|
||||
cb("tool.started", "terminal", "echo one", {"command": "echo one"})
|
||||
cb("tool.started", "terminal", "echo two", {"command": "echo two"})
|
||||
cb("tool.started", "terminal", "echo three", {"command": "echo three"})
|
||||
cb("tool.started", "web_search", "query stuff", {"query": "query stuff"})
|
||||
cb("tool.started", "terminal", "echo four", {"command": "echo four"})
|
||||
time.sleep(0.35)
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consecutive_terminal_progress_collapses_headers(monkeypatch, tmp_path):
|
||||
"""Back-to-back terminal calls render ONE "terminal" header followed by
|
||||
adjacent code blocks; a different tool in between resets the header so the
|
||||
next terminal call gets a fresh one."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = MultiTerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-consecutive",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
contents = [call["content"] for call in adapter.sent] + [
|
||||
call["content"] for call in adapter.edits
|
||||
]
|
||||
final = max(contents, key=len) if contents else ""
|
||||
# All four commands present as code blocks.
|
||||
for cmd in ("echo one", "echo two", "echo three", "echo four"):
|
||||
assert cmd in final
|
||||
# Exactly TWO terminal headers: one for the first run of three calls,
|
||||
# one for the terminal call after web_search broke the streak.
|
||||
assert final.count("terminal\n```") == 2
|
||||
|
||||
@@ -611,6 +611,30 @@ class TestSessionStoreSwitchSession:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestSessionStoreLookupBySessionId:
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
config = GatewayConfig()
|
||||
with patch("gateway.session.SessionStore._ensure_loaded"):
|
||||
s = SessionStore(sessions_dir=tmp_path, config=config)
|
||||
s._db = None
|
||||
s._loaded = True
|
||||
return s
|
||||
|
||||
def test_returns_active_entry_for_persisted_session_id(self, store):
|
||||
source = SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id="!room:example.org",
|
||||
chat_type="group",
|
||||
user_id="@alice:example.org",
|
||||
)
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
assert store.lookup_by_session_id(entry.session_id) is entry
|
||||
assert store.lookup_by_session_id("missing") is None
|
||||
assert store.lookup_by_session_id("") is None
|
||||
|
||||
|
||||
class TestWhatsAppSessionKeyConsistency:
|
||||
"""Regression: WhatsApp session keys must collapse JID/LID aliases to a
|
||||
single stable identity for both DM chat_ids and group participant_ids."""
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Tests for the WhatsApp stale-bridge staleness handshake.
|
||||
|
||||
Regression tests for the stale-bridge trap: ``connect()`` reused any
|
||||
already-running bridge with ``status: connected`` unconditionally, and
|
||||
``disconnect()`` only kills bridges the adapter spawned itself. A
|
||||
long-lived bridge process therefore survived gateway restarts AND
|
||||
``hermes update``, serving pre-update bridge.js behavior forever (e.g.
|
||||
no inbound media download → images/voice notes arrive as placeholders).
|
||||
|
||||
The fix: bridge.js reports a hash of its own source in ``/health``
|
||||
(``scriptHash``); the adapter compares it against the bridge.js on disk
|
||||
and restarts the bridge on mismatch. Bridges that predate the handshake
|
||||
report no hash and are treated as stale by definition.
|
||||
|
||||
Also covers the npm dependency-refresh stamp: deps are reinstalled when
|
||||
package.json changes, not only when node_modules is missing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
|
||||
|
||||
class _AsyncCM:
|
||||
"""Minimal async context manager returning a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_adapter(bridge_script: str = "/tmp/test-bridge.js",
|
||||
session_path: Path = Path("/tmp/test-wa-session")):
|
||||
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
|
||||
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||
adapter.platform = Platform.WHATSAPP
|
||||
adapter.config = MagicMock()
|
||||
adapter._bridge_port = 19876
|
||||
adapter._bridge_script = bridge_script
|
||||
adapter._session_path = session_path
|
||||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = False
|
||||
adapter._message_handler = None
|
||||
adapter._fatal_error_code = None
|
||||
adapter._fatal_error_message = None
|
||||
adapter._fatal_error_retryable = True
|
||||
adapter._fatal_error_handler = None
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
adapter._http_session = None
|
||||
return adapter
|
||||
|
||||
|
||||
def _mock_health(json_data):
|
||||
"""Mock aiohttp.ClientSession whose GET returns 200 + *json_data*."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=json_data)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_AsyncCM(mock_resp))
|
||||
mock_session.close = AsyncMock()
|
||||
return MagicMock(return_value=_AsyncCM(mock_session))
|
||||
|
||||
|
||||
def _setup_bridge_dir(tmp_path: Path) -> Path:
|
||||
"""Create a real bridge dir with bridge.js + package.json + creds."""
|
||||
bridge_dir = tmp_path / "whatsapp-bridge"
|
||||
bridge_dir.mkdir()
|
||||
(bridge_dir / "bridge.js").write_text("// current bridge code\n")
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge"}\n')
|
||||
session_path = tmp_path / "session"
|
||||
session_path.mkdir()
|
||||
(session_path / "creds.json").write_text("{}")
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def _fresh_node_modules(bridge_dir: Path) -> None:
|
||||
"""Create node_modules with a stamp matching the current package.json."""
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
nm = bridge_dir / "node_modules"
|
||||
nm.mkdir()
|
||||
(nm / ".hermes-pkg-hash").write_text(
|
||||
_file_content_hash(bridge_dir / "package.json")
|
||||
)
|
||||
|
||||
|
||||
class TestFileContentHash:
|
||||
def test_hashes_file(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h = _file_content_hash(f)
|
||||
assert len(h) == 16
|
||||
assert h == _file_content_hash(f) # deterministic
|
||||
|
||||
def test_changes_with_content(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h1 = _file_content_hash(f)
|
||||
f.write_text("def")
|
||||
assert _file_content_hash(f) != h1
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
assert _file_content_hash(tmp_path / "nope.js") == ""
|
||||
|
||||
def test_matches_bridge_js_self_hash_algorithm(self, tmp_path):
|
||||
"""Python and Node must compute the same hash for the same bytes."""
|
||||
import hashlib
|
||||
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "bridge.js"
|
||||
f.write_bytes(b"const x = 1;\n")
|
||||
# Node side: createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
||||
expected = hashlib.sha256(b"const x = 1;\n").hexdigest()[:16]
|
||||
assert _file_content_hash(f) == expected
|
||||
|
||||
|
||||
class TestStaleBridgeHandshake:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_bridge_when_hash_matches(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
disk_hash = _file_content_hash(bridge_dir / "bridge.js")
|
||||
mock_client = _mock_health({"status": "connected", "scriptHash": disk_hash})
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.create_task") as mock_task, \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True), \
|
||||
patch.object(adapter, "_mark_connected", create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is True
|
||||
mock_popen.assert_not_called() # reused, never spawned
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_bridge_on_hash_mismatch(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_client = _mock_health(
|
||||
{"status": "connected", "scriptHash": "deadbeefdeadbeef"}
|
||||
)
|
||||
# Spawned bridge dies immediately → connect() returns False, but the
|
||||
# assertion that matters is that the stale bridge was NOT reused and
|
||||
# a new process spawn was attempted.
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process") as mock_kill_port, \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False # mock proc died; not the point of the test
|
||||
mock_popen.assert_called_once() # stale bridge replaced, not reused
|
||||
mock_kill_port.assert_called_once_with(adapter._bridge_port)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_unversioned_bridge(self, tmp_path):
|
||||
"""Bridges predating the handshake report no scriptHash → stale."""
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
# Old bridge /health payload: no scriptHash key at all
|
||||
mock_client = _mock_health({"status": "connected"})
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
|
||||
class TestDepRefreshStamp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_install_when_stamp_fresh(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinstalls_when_package_json_changed(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
# Simulate `hermes update` bumping the Baileys pin
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge", "v": 2}\n')
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert "install" in mock_run.call_args[0][0]
|
||||
# Stamp updated to the new package.json hash
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
stamp = (bridge_dir / "node_modules" / ".hermes-pkg-hash").read_text().strip()
|
||||
assert stamp == _file_content_hash(bridge_dir / "package.json")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installs_when_node_modules_missing(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path) # no node_modules
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
def _npm_install(*args, **kwargs):
|
||||
# npm creates node_modules as a side effect
|
||||
(bridge_dir / "node_modules").mkdir(exist_ok=True)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", side_effect=_npm_install) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestCacheDirEnvPassthrough:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_spawn_env_has_cache_dirs(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
env = mock_popen.call_args.kwargs["env"]
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir,
|
||||
get_document_cache_dir,
|
||||
get_image_cache_dir,
|
||||
)
|
||||
assert env["HERMES_IMAGE_CACHE_DIR"] == str(get_image_cache_dir())
|
||||
assert env["HERMES_AUDIO_CACHE_DIR"] == str(get_audio_cache_dir())
|
||||
assert env["HERMES_DOCUMENT_CACHE_DIR"] == str(get_document_cache_dir())
|
||||
@@ -146,6 +146,12 @@ class TestShouldExclude:
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("logs/agent.log"))
|
||||
|
||||
def test_includes_nested_hermes_agent_in_skills(self):
|
||||
"""skills/autonomous-ai-agents/hermes-agent/ must NOT be excluded —
|
||||
only the root-level hermes-agent/ repo is skipped."""
|
||||
from hermes_cli.backup import _should_exclude
|
||||
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/SKILL.md"))
|
||||
assert not _should_exclude(Path("skills/autonomous-ai-agents/hermes-agent/sub/item.txt"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup tests
|
||||
@@ -186,6 +192,66 @@ class TestBackup:
|
||||
# Skins
|
||||
assert "skins/cyber.yaml" in names
|
||||
|
||||
def test_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
|
||||
"""SQLite staging temp files must be created on the output zip's
|
||||
filesystem (dir=out_path.parent), NOT the system /tmp default — a
|
||||
small tmpfs there silently drops large DBs from the backup (#35376)."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_dir = tmp_path / "external-drive"
|
||||
out_dir.mkdir()
|
||||
out_zip = out_dir / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
import hermes_cli.backup as backup_mod
|
||||
staged_dirs = []
|
||||
real_ntf = backup_mod.tempfile.NamedTemporaryFile
|
||||
|
||||
def _spy(*a, **kw):
|
||||
staged_dirs.append(kw.get("dir"))
|
||||
return real_ntf(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
|
||||
backup_mod.run_backup(args)
|
||||
|
||||
# At least one .db was staged, and every staging call targeted the
|
||||
# output zip's directory rather than the system temp default.
|
||||
assert staged_dirs, "no SQLite snapshot was staged"
|
||||
assert all(d == str(out_dir) for d in staged_dirs), staged_dirs
|
||||
|
||||
def test_pre_update_db_snapshots_staged_beside_output_zip(self, tmp_path, monkeypatch):
|
||||
"""The pre-update/pre-migration zip path (_write_full_zip_backup) must
|
||||
also stage SQLite snapshots beside its output zip, not in /tmp."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = hermes_home / "backups" / "pre-update-test.zip"
|
||||
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import hermes_cli.backup as backup_mod
|
||||
staged_dirs = []
|
||||
real_ntf = backup_mod.tempfile.NamedTemporaryFile
|
||||
|
||||
def _spy(*a, **kw):
|
||||
staged_dirs.append(kw.get("dir"))
|
||||
return real_ntf(*a, **kw)
|
||||
|
||||
monkeypatch.setattr(backup_mod.tempfile, "NamedTemporaryFile", _spy)
|
||||
result = backup_mod._write_full_zip_backup(out_zip, hermes_home)
|
||||
|
||||
assert result is not None
|
||||
assert staged_dirs, "no SQLite snapshot was staged"
|
||||
assert all(d == str(out_zip.parent) for d in staged_dirs), staged_dirs
|
||||
|
||||
def test_excludes_hermes_agent(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include hermes-agent/ directory."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
@@ -206,6 +272,37 @@ class TestBackup:
|
||||
agent_files = [n for n in names if "hermes-agent" in n]
|
||||
assert agent_files == [], f"hermes-agent files leaked into backup: {agent_files}"
|
||||
|
||||
def test_includes_nested_hermes_agent_in_skills(self, tmp_path, monkeypatch):
|
||||
"""Backup includes skills/.../hermes-agent/ but NOT root hermes-agent/."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
_make_hermes_tree(hermes_home)
|
||||
|
||||
# Add a nested hermes-agent directory inside skills (like the real layout)
|
||||
nested = hermes_home / "skills" / "autonomous-ai-agents" / "hermes-agent"
|
||||
nested.mkdir(parents=True)
|
||||
(nested / "SKILL.md").write_text("# Hermes Agent Skill\n")
|
||||
(nested / "sub").mkdir()
|
||||
(nested / "sub" / "item.txt").write_text("nested content\n")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
out_zip = tmp_path / "backup.zip"
|
||||
args = Namespace(output=str(out_zip))
|
||||
|
||||
from hermes_cli.backup import run_backup
|
||||
run_backup(args)
|
||||
|
||||
with zipfile.ZipFile(out_zip, "r") as zf:
|
||||
names = zf.namelist()
|
||||
# Root hermes-agent must be excluded
|
||||
root_agent = [n for n in names if n.startswith("hermes-agent/")]
|
||||
assert root_agent == [], f"root hermes-agent leaked: {root_agent}"
|
||||
# Nested skill hermes-agent must be included
|
||||
assert "skills/autonomous-ai-agents/hermes-agent/SKILL.md" in names
|
||||
assert "skills/autonomous-ai-agents/hermes-agent/sub/item.txt" in names
|
||||
|
||||
def test_excludes_pycache(self, tmp_path, monkeypatch):
|
||||
"""Backup does NOT include __pycache__ dirs."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
||||
+3
-4
@@ -47,20 +47,19 @@ def test_cron_aliases():
|
||||
def test_cron_create_options():
|
||||
parser = _build()
|
||||
ns = parser.parse_args([
|
||||
"cron", "create", "0 9 * * *", "do the thing",
|
||||
"cron", "create", "0 9 * * *", "daily task prompt",
|
||||
"--name", "daily", "--deliver", "origin", "--repeat", "3",
|
||||
"--skill", "a", "--skill", "b", "--no-agent",
|
||||
"--workdir", "/tmp/x", "--profile", "work",
|
||||
"--workdir", "/tmp/x",
|
||||
])
|
||||
assert ns.schedule == "0 9 * * *"
|
||||
assert ns.prompt == "do the thing"
|
||||
assert ns.prompt == "daily task prompt"
|
||||
assert ns.name == "daily"
|
||||
assert ns.deliver == "origin"
|
||||
assert ns.repeat == 3
|
||||
assert ns.skills == ["a", "b"]
|
||||
assert ns.no_agent is True
|
||||
assert ns.workdir == "/tmp/x"
|
||||
assert ns.profile == "work"
|
||||
|
||||
|
||||
def test_cron_edit_no_agent_tristate():
|
||||
@@ -201,6 +201,91 @@ class TestWebhookEndpoints:
|
||||
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_enable_platform_starts_gateway_restart(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
restart_calls = []
|
||||
|
||||
class FakeRestartProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_spawn_action(subcommand, name):
|
||||
restart_calls.append((subcommand, name))
|
||||
return FakeRestartProc()
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"ok": True,
|
||||
"platform": "webhook",
|
||||
"enabled": True,
|
||||
"needs_restart": False,
|
||||
"restart_started": True,
|
||||
"restart_action": "gateway-restart",
|
||||
"restart_pid": 4242,
|
||||
}
|
||||
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
assert self.client.get("/api/webhooks").json()["enabled"] is True
|
||||
|
||||
def test_enable_platform_reports_restart_failure_after_save(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
assert subcommand == ["gateway", "restart"]
|
||||
assert name == "gateway-restart"
|
||||
raise RuntimeError("supervisor unavailable")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["platform"] == "webhook"
|
||||
assert data["enabled"] is True
|
||||
assert data["needs_restart"] is True
|
||||
assert data["restart_started"] is False
|
||||
assert "supervisor unavailable" in data["restart_error"]
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
|
||||
def test_enable_platform_reuses_inflight_gateway_restart(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
ws._ACTION_PROCS.pop("gateway-restart", None)
|
||||
|
||||
class FakeRunningProc:
|
||||
pid = 5151
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
|
||||
|
||||
def fail_spawn_action(subcommand, name):
|
||||
raise AssertionError("must not spawn a second concurrent restart")
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
|
||||
|
||||
r = self.client.post("/api/webhooks/enable")
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["needs_restart"] is False
|
||||
assert data["restart_started"] is True
|
||||
assert data["restart_pid"] == 5151
|
||||
assert load_config()["platforms"]["webhook"]["enabled"] is True
|
||||
|
||||
|
||||
class TestOpsEndpoints:
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -622,6 +707,10 @@ class TestAdminEndpointsAuthGate:
|
||||
resp = self.client.get(path)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_webhooks_enable_post_gated(self):
|
||||
resp = self.client.post("/api/webhooks/enable")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
class TestUpdateCheckEndpoint:
|
||||
"""``GET /api/hermes/update/check`` reports availability without applying.
|
||||
@@ -953,4 +1042,3 @@ class TestToolsConfigEndpoints:
|
||||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for the unified profile→machine dashboard launch routing.
|
||||
|
||||
`<profile> dashboard` routes to ONE machine-level dashboard instead of
|
||||
spawning a per-profile server: attach (open browser at ?profile=) when one
|
||||
is already listening, else re-exec as the machine dashboard with the
|
||||
launching profile preselected. `--isolated` opts out.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def main_mod():
|
||||
import hermes_cli.main as main_mod
|
||||
return main_mod
|
||||
|
||||
|
||||
def _args(**kw):
|
||||
defaults = dict(
|
||||
status=False, stop=False, host="127.0.0.1", port=9119,
|
||||
no_open=True, insecure=False, skip_build=False,
|
||||
isolated=False, open_profile="",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return types.SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
class TestUnifiedDashboardRouting:
|
||||
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert exc.value.code == 0
|
||||
assert execs == [] # attached, never re-exec'd
|
||||
|
||||
def test_profile_launch_attach_opens_scoped_url(self, main_mod, monkeypatch):
|
||||
"""The attach path must open the browser at ?profile=<name> — that
|
||||
URL is the entire point of attaching (preselects the switcher)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
||||
opened = []
|
||||
import webbrowser
|
||||
monkeypatch.setattr(webbrowser, "open", lambda url: opened.append(url))
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
main_mod.cmd_dashboard(_args(no_open=False))
|
||||
assert exc.value.code == 0
|
||||
assert opened == ["http://127.0.0.1:9119/?profile=worker_x"]
|
||||
|
||||
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
|
||||
execs = []
|
||||
|
||||
def fake_exec(exe, argv, env):
|
||||
execs.append((exe, argv, env))
|
||||
raise SystemExit(0) # execvpe never returns
|
||||
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
|
||||
assert len(execs) == 1
|
||||
exe, argv, env = execs[0]
|
||||
assert exe == sys.executable
|
||||
# Pinned to the default profile + launching profile preselected.
|
||||
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
|
||||
assert "--open-profile" in argv
|
||||
assert argv[argv.index("--open-profile") + 1] == "worker_x"
|
||||
# Profile HERMES_HOME dropped so the child binds the machine root.
|
||||
assert "HERMES_HOME" not in env
|
||||
|
||||
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
# With --isolated the routing block is skipped entirely; the command
|
||||
# proceeds to dependency checks. Make the first post-routing step
|
||||
# bail so the test doesn't actually start a server.
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(isolated=True))
|
||||
assert listening_calls == []
|
||||
|
||||
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or True,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert listening_calls == []
|
||||
|
||||
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
|
||||
"""The re-exec'd child carries --open-profile; the guard must treat
|
||||
that as 'already routed' and never re-exec again (no exec loop)."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
|
||||
assert execs == []
|
||||
@@ -369,6 +369,16 @@ def test_systemd_install_checks_linger_status(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Synthetic unit with a non-temp home: the real generator bakes the
|
||||
# hermetic test HERMES_HOME (a tmp dir), which the temp-home write
|
||||
# guard correctly refuses.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
helper_calls = []
|
||||
@@ -396,6 +406,15 @@ def test_systemd_install_can_skip_enable_on_startup(monkeypatch, tmp_path, capsy
|
||||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Non-temp home so the temp-home write guard (which trips on the
|
||||
# hermetic test HERMES_HOME) stays out of the way.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
helper_calls = []
|
||||
|
||||
@@ -102,6 +102,15 @@ def test_systemd_install_calls_linger_helper(monkeypatch, tmp_path, capsys):
|
||||
unit_path = tmp_path / "systemd" / "user" / "hermes-gateway.service"
|
||||
|
||||
monkeypatch.setattr(gateway, "get_systemd_unit_path", lambda system=False: unit_path)
|
||||
# Non-temp home so the temp-home write guard (which trips on the
|
||||
# hermetic test HERMES_HOME) stays out of the way.
|
||||
monkeypatch.setattr(
|
||||
gateway,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: (
|
||||
'[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -289,6 +289,105 @@ class TestSystemdServiceRefresh:
|
||||
"daemon-reload" in str(c) for c in ran
|
||||
), "daemon-reload must not run when write was refused"
|
||||
|
||||
def test_refresh_refuses_to_bake_any_tempdir_home_into_real_user_unit(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Structural guard: a manual E2E HERMES_HOME like
|
||||
``/tmp/hermes-e2e-41264`` carries none of the pytest markers but
|
||||
poisons the unit identically (seen live 2026-06-11 — an E2E probe ran
|
||||
``hermes gateway restart`` with a /tmp HERMES_HOME exported; the
|
||||
restart's unit refresh baked it into the production unit and the
|
||||
post-update restart produced a 7-hour zombie gateway). The refresh
|
||||
must refuse ANY temp-dir HERMES_HOME, not just pytest-shaped ones.
|
||||
"""
|
||||
unit_path = tmp_path / "hermes-gateway.service"
|
||||
unit_path.write_text("old unit\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path
|
||||
)
|
||||
polluted_unit = (
|
||||
"[Service]\n"
|
||||
'Environment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
|
||||
"WorkingDirectory=/tmp/hermes-e2e-41264\n"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_systemd_unit",
|
||||
lambda system=False, run_as_user=None: polluted_unit,
|
||||
)
|
||||
|
||||
ran = []
|
||||
|
||||
def fake_run(cmd, check=True, **kwargs):
|
||||
ran.append(cmd)
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
|
||||
|
||||
result = gateway_cli.refresh_systemd_unit_if_needed(system=False)
|
||||
|
||||
assert result is False, "refresh should refuse to write a temp-home unit"
|
||||
assert (
|
||||
unit_path.read_text(encoding="utf-8") == "old unit\n"
|
||||
), "installed unit must be left untouched"
|
||||
assert not any(
|
||||
"daemon-reload" in str(c) for c in ran
|
||||
), "daemon-reload must not run when write was refused"
|
||||
|
||||
|
||||
class TestTempHomeServiceDefinitionGuard:
|
||||
"""_temp_home_in_service_definition() — structural temp-dir detection."""
|
||||
|
||||
def test_detects_tmp_home_in_systemd_unit(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/tmp/hermes-e2e-41264"\n'
|
||||
assert (
|
||||
gateway_cli._temp_home_in_service_definition(unit)
|
||||
== "/tmp/hermes-e2e-41264"
|
||||
)
|
||||
|
||||
def test_detects_var_tmp_home(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/var/tmp/hermes-x"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is not None
|
||||
|
||||
def test_detects_tempdir_env_home(self, monkeypatch, tmp_path):
|
||||
import tempfile as _tempfile
|
||||
|
||||
monkeypatch.setattr(_tempfile, "gettempdir", lambda: str(tmp_path))
|
||||
unit = f'[Service]\nEnvironment="HERMES_HOME={tmp_path}/hermes-home"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is not None
|
||||
|
||||
def test_detects_tmp_home_in_launchd_plist(self):
|
||||
plist = (
|
||||
"<dict>\n <key>HERMES_HOME</key>\n"
|
||||
" <string>/tmp/hermes-e2e-99999</string>\n</dict>\n"
|
||||
)
|
||||
assert (
|
||||
gateway_cli._temp_home_in_service_definition(plist)
|
||||
== "/tmp/hermes-e2e-99999"
|
||||
)
|
||||
|
||||
def test_accepts_real_home(self):
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/home/alice/.hermes"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
def test_accepts_macos_real_home_plist(self):
|
||||
plist = (
|
||||
"<dict>\n <key>HERMES_HOME</key>\n"
|
||||
" <string>/Users/alice/.hermes</string>\n</dict>\n"
|
||||
)
|
||||
assert gateway_cli._temp_home_in_service_definition(plist) is None
|
||||
|
||||
def test_accepts_unit_without_hermes_home(self):
|
||||
unit = "[Service]\nExecStart=/usr/bin/python -m hermes_cli.main gateway run\n"
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
def test_tmp_prefixed_non_temp_path_is_accepted(self):
|
||||
# /tmpfs-data is NOT under /tmp — prefix matching must be
|
||||
# component-wise, not string startswith.
|
||||
unit = '[Service]\nEnvironment="HERMES_HOME=/tmpfs-data/.hermes"\n'
|
||||
assert gateway_cli._temp_home_in_service_definition(unit) is None
|
||||
|
||||
|
||||
class TestRequireServiceInstalled:
|
||||
def test_exits_with_install_hint_when_unit_missing(self, tmp_path, monkeypatch, capsys):
|
||||
@@ -481,6 +580,17 @@ class TestLaunchdServiceRecovery:
|
||||
plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
# Patch the generator with synthetic content carrying a real-looking
|
||||
# home — the temp-home guard refuses to write plists whose
|
||||
# HERMES_HOME resolves under the (pytest tmp) test HERMES_HOME.
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_launchd_plist",
|
||||
lambda: (
|
||||
"<plist>--replace\n<key>HERMES_HOME</key>"
|
||||
"<string>/Users/alice/.hermes</string></plist>"
|
||||
),
|
||||
)
|
||||
|
||||
calls = []
|
||||
|
||||
@@ -776,6 +886,17 @@ class TestLaunchdServiceRecovery:
|
||||
"""macOS bootstrap error 5 should spawn a detached gateway, not crash."""
|
||||
plist_path = tmp_path / "ai.hermes.gateway.plist"
|
||||
monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
|
||||
# Synthetic plist with a non-temp home so the temp-home write guard
|
||||
# (which would trip on the pytest-tmp test HERMES_HOME) stays out of
|
||||
# the way — this test exercises the bootstrap-error fallback.
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"generate_launchd_plist",
|
||||
lambda: (
|
||||
"<plist><key>HERMES_HOME</key>"
|
||||
"<string>/Users/alice/.hermes</string></plist>"
|
||||
),
|
||||
)
|
||||
|
||||
def fake_run(cmd, check=False, **kwargs):
|
||||
if cmd[:2] == ["launchctl", "bootstrap"]:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Regression tests for the post-update gateway respawn interpreter on Windows.
|
||||
|
||||
Background
|
||||
----------
|
||||
When the Desktop GUI runs ``hermes update``, it spawns the post-update
|
||||
respawn watcher via
|
||||
``hermes_cli.gateway.launch_detached_profile_gateway_restart``. That watcher
|
||||
polls the old gateway PID and, once it exits, respawns the gateway using the
|
||||
argv built by ``_gateway_run_args_for_profile``.
|
||||
|
||||
The bug: that argv used ``get_python_path()`` — the *console* ``python.exe``.
|
||||
For uv-created venvs, even ``venv\\Scripts\\pythonw.exe`` re-execs the base
|
||||
interpreter as a console ``python.exe`` (the re-exec is a fresh CreateProcess
|
||||
that does NOT inherit ``CREATE_NO_WINDOW``), so a blank console window pops up
|
||||
after every GUI-driven update. ``hermes gateway start`` avoided this by going
|
||||
through ``_resolve_detached_python`` to get the *base* ``pythonw.exe`` plus a
|
||||
``VIRTUAL_ENV`` / ``PYTHONPATH`` overlay; the post-update respawn path never
|
||||
got the same treatment.
|
||||
|
||||
These tests lock in:
|
||||
* Windows respawn argv resolves to the base ``pythonw.exe`` (not the venv
|
||||
``Scripts`` shim, not console ``python.exe``).
|
||||
* The respawn env carries the matching ``VIRTUAL_ENV`` / ``PYTHONPATH`` so a
|
||||
base-interpreter respawn can still import ``hermes_cli``.
|
||||
* POSIX behaviour is byte-for-byte unchanged (argv keeps ``sys.executable``'s
|
||||
resolved path; env overlay is a no-op).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gateway as gateway
|
||||
import hermes_cli.gateway_windows as gateway_windows
|
||||
|
||||
|
||||
def _make_uv_venv(tmp_path: Path) -> dict[str, Path]:
|
||||
"""Fabricate a uv-style venv layout: venv Scripts python(w).exe + a base
|
||||
interpreter referenced by pyvenv.cfg's ``home`` with its own pythonw.exe."""
|
||||
project = tmp_path / "project"
|
||||
scripts = project / "venv" / "Scripts"
|
||||
site_packages = project / "venv" / "Lib" / "site-packages"
|
||||
base = tmp_path / "uv" / "python" / "cpython-3.11-windows-x86_64-none"
|
||||
for directory in (scripts, site_packages, base):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
venv_python = scripts / "python.exe"
|
||||
venv_pythonw = scripts / "pythonw.exe"
|
||||
base_pythonw = base / "pythonw.exe"
|
||||
for exe in (venv_python, venv_pythonw, base_pythonw):
|
||||
exe.write_text("", encoding="utf-8")
|
||||
(project / "venv" / "pyvenv.cfg").write_text(
|
||||
f"home = {base}\nimplementation = CPython\nuv = 0.11.14\nversion_info = 3.11.15\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"project": project,
|
||||
"venv_python": venv_python,
|
||||
"venv_pythonw": venv_pythonw,
|
||||
"base_pythonw": base_pythonw,
|
||||
"site_packages": site_packages,
|
||||
}
|
||||
|
||||
|
||||
class TestRespawnArgvUsesBasePythonw:
|
||||
def test_windows_uv_venv_resolves_base_pythonw(self, tmp_path, monkeypatch):
|
||||
"""The respawn argv must use the base pythonw.exe for a uv venv,
|
||||
never the venv Scripts shim or console python.exe."""
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
|
||||
assert argv[0] == str(layout["base_pythonw"])
|
||||
assert argv[0] != str(layout["venv_python"])
|
||||
assert argv[0] != str(layout["venv_pythonw"])
|
||||
assert argv[1:] == ["-m", "hermes_cli.main", "gateway", "run", "--replace"]
|
||||
|
||||
def test_windows_non_default_profile_keeps_profile_arg(self, tmp_path, monkeypatch):
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("work")
|
||||
|
||||
assert argv[0] == str(layout["base_pythonw"])
|
||||
assert argv[1:] == [
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"--profile",
|
||||
"work",
|
||||
"gateway",
|
||||
"run",
|
||||
"--replace",
|
||||
]
|
||||
|
||||
|
||||
class TestRespawnEnvOverlay:
|
||||
def test_windows_overlay_sets_virtualenv_and_pythonpath(self, tmp_path, monkeypatch):
|
||||
"""A base-pythonw respawn needs VIRTUAL_ENV + PYTHONPATH so imports
|
||||
resolve without the venv launcher shim."""
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
|
||||
env = gateway._gateway_respawn_env({})
|
||||
|
||||
assert env["VIRTUAL_ENV"] == str(layout["project"] / "venv")
|
||||
assert env["HERMES_GATEWAY_DETACHED"] == "1"
|
||||
assert "PYTHONPATH" in env
|
||||
# Repo root and the base-interpreter site-packages must both be on it.
|
||||
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
|
||||
assert str(layout["site_packages"]) in env["PYTHONPATH"]
|
||||
|
||||
def test_windows_overlay_prepends_to_existing_pythonpath(self, tmp_path, monkeypatch):
|
||||
layout = _make_uv_venv(tmp_path)
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: True)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: str(layout["venv_python"]))
|
||||
monkeypatch.setenv("PYTHONPATH", "/preexisting/entry")
|
||||
|
||||
env = gateway._gateway_respawn_env({})
|
||||
|
||||
assert env["PYTHONPATH"].endswith("/preexisting/entry")
|
||||
assert str(gateway.PROJECT_ROOT) in env["PYTHONPATH"]
|
||||
|
||||
|
||||
class TestPosixUnchanged:
|
||||
"""POSIX must be byte-for-byte identical to the pre-fix behaviour."""
|
||||
|
||||
def test_posix_argv_uses_get_python_path_verbatim(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
monkeypatch.setattr(gateway, "get_python_path", lambda: "/usr/bin/python3")
|
||||
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
|
||||
assert argv == [
|
||||
"/usr/bin/python3",
|
||||
"-m",
|
||||
"hermes_cli.main",
|
||||
"gateway",
|
||||
"run",
|
||||
"--replace",
|
||||
]
|
||||
|
||||
def test_posix_env_overlay_is_noop(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway, "is_windows", lambda: False)
|
||||
original = {"PATH": "/usr/bin", "FOO": "bar"}
|
||||
|
||||
result = gateway._gateway_respawn_env(dict(original))
|
||||
|
||||
assert result == original
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific regression")
|
||||
class TestLiveWindowsRespawn:
|
||||
"""On a real Windows host the resolver should pick a *windowed* interpreter
|
||||
(pythonw) for the running gateway's own venv, with no console flag needed."""
|
||||
|
||||
def test_resolved_interpreter_is_windowless(self):
|
||||
argv = gateway._gateway_run_args_for_profile("default")
|
||||
assert argv[0].lower().endswith("pythonw.exe")
|
||||
@@ -425,3 +425,43 @@ def test_tui_launch_install_uses_workspace_scope(
|
||||
install_cmd = npm_calls[0]
|
||||
assert "--workspace" in install_cmd
|
||||
assert "ui-tui" in install_cmd
|
||||
|
||||
def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
"""When ui-tui/ has its own package-lock.json, _workspace_root returns
|
||||
tui_dir itself. npm install --workspace ui-tui would fail in that case
|
||||
because npm cannot find a workspace named "ui-tui" inside ui-tui/.
|
||||
The fix omits --workspace and runs plain npm install from tui_dir.
|
||||
See #42973.
|
||||
"""
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
# Simulate curl-install layout: tui_dir has its own lockfile
|
||||
(tui_dir / "package-lock.json").write_text("{}")
|
||||
# Parent also has lockfile (but _workspace_root prefers tui_dir's own)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
install_cmd = calls[0][0][0]
|
||||
# Must NOT contain --workspace when npm_cwd == tui_dir
|
||||
assert "--workspace" not in install_cmd, (
|
||||
f"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}"
|
||||
)
|
||||
assert install_cmd[:2] == ["/bin/npm", "install"]
|
||||
# cwd must be tui_dir (standalone), not parent
|
||||
assert calls[0][1]["cwd"] == str(tui_dir)
|
||||
|
||||
@@ -93,7 +93,39 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
|
||||
result = check_for_updates()
|
||||
|
||||
assert result == 5
|
||||
assert mock_run.call_count == 2 # git fetch + git rev-list
|
||||
assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list
|
||||
|
||||
|
||||
def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path):
|
||||
"""Passive update checks must not trigger SSH auth for official installs."""
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
repo_dir = tmp_path / "hermes-agent"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / ".git").mkdir()
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == ["git", "remote", "get-url", "origin"]:
|
||||
return MagicMock(returncode=0, stdout="git@github.com:NousResearch/hermes-agent.git\n")
|
||||
if cmd == ["git", "rev-parse", "HEAD"]:
|
||||
return MagicMock(returncode=0, stdout="local-sha\n")
|
||||
if cmd == [
|
||||
"git",
|
||||
"ls-remote",
|
||||
"https://github.com/NousResearch/hermes-agent.git",
|
||||
"refs/heads/main",
|
||||
]:
|
||||
return MagicMock(returncode=0, stdout="upstream-sha\trefs/heads/main\n")
|
||||
raise AssertionError(f"unexpected git command: {cmd!r}")
|
||||
|
||||
with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run):
|
||||
result = banner._check_via_local_git(repo_dir)
|
||||
|
||||
assert result == banner.UPDATE_AVAILABLE_NO_COUNT
|
||||
assert ["git", "fetch", "origin", "--quiet"] not in calls
|
||||
|
||||
|
||||
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
|
||||
|
||||
@@ -1104,6 +1104,113 @@ class TestWebServerEndpoints:
|
||||
assert confirmed.status_code == 200
|
||||
assert confirmed.json()["ok"] is True
|
||||
|
||||
def test_model_set_normalizes_vendor_slug_for_native_provider(self, monkeypatch):
|
||||
"""'Use as → Main' with an OpenRouter slug + native provider must not
|
||||
persist the vendor-prefixed slug verbatim (it 400s against the native
|
||||
API and reads as "changing models does nothing")."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "anthropic",
|
||||
"model": "anthropic/claude-opus-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "anthropic"
|
||||
# Vendor prefix stripped + dots→hyphens for the native Anthropic API.
|
||||
assert data["model"] == "claude-opus-4-6"
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["provider"] == "anthropic"
|
||||
assert cfg["model"]["default"] == "claude-opus-4-6"
|
||||
|
||||
def test_model_set_maps_unknown_vendor_to_aggregator(self, monkeypatch):
|
||||
"""A bare vendor name from analytics rows (no billing_provider) is not
|
||||
a Hermes provider — keep the user's aggregator instead of writing a
|
||||
provider that can never resolve credentials."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
from hermes_cli.config import load_config, save_config
|
||||
cfg = load_config()
|
||||
cfg["model"] = {"provider": "openrouter", "default": "openai/gpt-5.5"}
|
||||
save_config(cfg)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "moonshotai", # vendor prefix, not a provider
|
||||
"model": "moonshotai/kimi-k2.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "moonshotai/kimi-k2.6"
|
||||
|
||||
def test_model_set_keeps_aggregator_slug_unchanged(self, monkeypatch):
|
||||
"""The happy path (picker → openrouter + vendor/model) is untouched."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["provider"] == "openrouter"
|
||||
assert data["model"] == "anthropic/claude-sonnet-4.6"
|
||||
|
||||
def test_ops_import_passes_force_flag(self, tmp_path, monkeypatch):
|
||||
"""force=True must append --force so the spawned non-interactive
|
||||
`hermes import` doesn't auto-abort at the overwrite prompt."""
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
archive = tmp_path / "backup.zip"
|
||||
import zipfile
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("config.yaml", "model: {}\n")
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
captured["args"] = subcommand
|
||||
captured["name"] = name
|
||||
from types import SimpleNamespace as NS
|
||||
return NS(pid=12345)
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive), "force": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive), "--force"]
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/ops/import", json={"archive": str(archive)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert captured["args"] == ["import", str(archive)]
|
||||
|
||||
|
||||
def test_reveal_env_var(self, tmp_path):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
@@ -4441,7 +4548,7 @@ class TestPtyWebSocket:
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
@@ -4454,7 +4561,7 @@ class TestPtyWebSocket:
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
@@ -4467,7 +4574,7 @@ class TestPtyWebSocket:
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
@@ -4497,7 +4604,7 @@ class TestPtyWebSocket:
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
with self.client.websocket_connect(self._url()) as conn:
|
||||
conn.send_bytes(b"round-trip-payload\n")
|
||||
@@ -4530,7 +4637,7 @@ class TestPtyWebSocket:
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
# sleep gives the test time to push the resize before the child reads the ioctl.
|
||||
lambda resume=None, sidecar_url=None: (
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
[sys.executable, "-c", winsize_script],
|
||||
None,
|
||||
None,
|
||||
@@ -4566,7 +4673,7 @@ class TestPtyWebSocket:
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None: (["/bin/cat"], None, None),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
# Patch PtyBridge.spawn at the web_server module's binding.
|
||||
import hermes_cli.web_server as ws_mod
|
||||
@@ -4581,7 +4688,7 @@ class TestPtyWebSocket:
|
||||
def test_resume_parameter_is_forwarded_to_argv(self, monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["resume"] = resume
|
||||
return (["/bin/sh", "-c", "printf resume-arg-ok"], None, None)
|
||||
|
||||
@@ -4601,7 +4708,7 @@ class TestPtyWebSocket:
|
||||
same channel — which is how tool events reach the dashboard sidebar."""
|
||||
captured: dict = {}
|
||||
|
||||
def fake_resolve(resume=None, sidecar_url=None):
|
||||
def fake_resolve(resume=None, sidecar_url=None, profile=None):
|
||||
captured["sidecar_url"] = sidecar_url
|
||||
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Regression tests for the machine-dashboard multi-profile unification.
|
||||
|
||||
The dashboard is ONE machine-level management surface: config, env, MCP,
|
||||
model, and chat-PTY endpoints accept an optional ``profile`` so the global
|
||||
profile switcher can target any profile's HERMES_HOME. These tests pin:
|
||||
reads/writes land in the REQUESTED profile, the dashboard's own profile
|
||||
stays untouched, and the chat PTY env is scoped via HERMES_HOME.
|
||||
"""
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with config + .env."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_beta"
|
||||
for home in (default_home, worker_home):
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
(worker_home / ".env").write_text("", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_beta": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
def _cfg(home):
|
||||
return yaml.safe_load((home / "config.yaml").read_text()) or {}
|
||||
|
||||
|
||||
class TestProfileScopedConfig:
|
||||
def test_config_put_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config",
|
||||
json={"config": {"timezone": "Mars/Olympus"}, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Mars/Olympus"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Mars/Olympus"
|
||||
|
||||
def test_config_get_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"timezone: Venus/Cloud\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/config", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("timezone") == "Venus/Cloud"
|
||||
# Unscoped read sees the dashboard's own config.
|
||||
resp = client.get("/api/config")
|
||||
assert resp.json().get("timezone") != "Venus/Cloud"
|
||||
|
||||
def test_config_query_param_equivalent_to_body(self, client, isolated_profiles):
|
||||
"""The SPA's fetchJSON injects ?profile= — must scope like body.profile."""
|
||||
resp = client.put(
|
||||
"/api/config?profile=worker_beta",
|
||||
json={"config": {"timezone": "Pluto/Far"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert _cfg(isolated_profiles["worker_beta"]).get("timezone") == "Pluto/Far"
|
||||
assert _cfg(isolated_profiles["default"]).get("timezone") != "Pluto/Far"
|
||||
|
||||
def test_config_raw_round_trip_scoped(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/config/raw",
|
||||
json={"yaml_text": "timezone: Io/Volcano\n", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
|
||||
assert "Io/Volcano" in resp.json()["yaml"]
|
||||
resp = client.get("/api/config/raw")
|
||||
assert "Io/Volcano" not in resp.json()["yaml"]
|
||||
|
||||
def test_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/config", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedEnv:
|
||||
def test_env_set_lands_in_target_profile_only(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "value": "test-fal-123", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
assert "test-fal-123" in worker_env
|
||||
default_env_path = isolated_profiles["default"] / ".env"
|
||||
if default_env_path.exists():
|
||||
assert "test-fal-123" not in default_env_path.read_text()
|
||||
|
||||
def test_env_list_reads_target_profile(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=worker-only-value\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.get("/api/env", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is True
|
||||
resp = client.get("/api/env")
|
||||
assert resp.json()["FAL_KEY"]["is_set"] is False
|
||||
|
||||
def test_env_delete_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / ".env").write_text(
|
||||
"FAL_KEY=doomed\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.request(
|
||||
"DELETE",
|
||||
"/api/env",
|
||||
json={"key": "FAL_KEY", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "doomed" not in (isolated_profiles["worker_beta"] / ".env").read_text()
|
||||
|
||||
|
||||
class TestProfileScopedMcp:
|
||||
def test_mcp_add_and_list_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/mcp/servers",
|
||||
json={"name": "scoped-srv", "url": "http://localhost:1234/sse",
|
||||
"profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert "scoped-srv" in worker_cfg.get("mcp_servers", {})
|
||||
assert "scoped-srv" not in _cfg(isolated_profiles["default"]).get("mcp_servers", {})
|
||||
|
||||
listing = client.get("/api/mcp/servers", params={"profile": "worker_beta"}).json()
|
||||
assert any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
listing = client.get("/api/mcp/servers").json()
|
||||
assert not any(s["name"] == "scoped-srv" for s in listing["servers"])
|
||||
|
||||
def test_mcp_enabled_toggle_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv1:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
resp = client.put(
|
||||
"/api/mcp/servers/srv1/enabled",
|
||||
json={"enabled": False, "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
assert worker_cfg["mcp_servers"]["srv1"]["enabled"] is False
|
||||
|
||||
def test_mcp_probe_runs_inside_profile_scope(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""The test-server probe must execute with the selected profile's
|
||||
scope active so env-placeholder expansion reads the profile's .env,
|
||||
matching the config the server was saved into."""
|
||||
import hermes_cli.mcp_config as mcp_config
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n probe-srv:\n url: http://x/sse\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
seen = {}
|
||||
|
||||
def fake_probe(name, config, connect_timeout=30):
|
||||
seen["home"] = str(get_hermes_home())
|
||||
return [("tool-a", "desc")]
|
||||
|
||||
monkeypatch.setattr(mcp_config, "_probe_single_server", fake_probe)
|
||||
resp = client.post(
|
||||
"/api/mcp/servers/probe-srv/test", params={"profile": "worker_beta"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert seen["home"] == str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_mcp_remove_scoped(self, client, isolated_profiles):
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"mcp_servers:\n srv2:\n url: http://x/sse\n", encoding="utf-8"
|
||||
)
|
||||
# Removing from the DASHBOARD's profile must 404 (srv2 lives in worker).
|
||||
resp = client.delete("/api/mcp/servers/srv2")
|
||||
assert resp.status_code == 404
|
||||
resp = client.delete("/api/mcp/servers/srv2", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert "srv2" not in _cfg(isolated_profiles["worker_beta"]).get("mcp_servers", {})
|
||||
|
||||
|
||||
class TestProfileScopedModel:
|
||||
def test_model_set_main_scoped(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/model/set",
|
||||
json={
|
||||
"scope": "main",
|
||||
"provider": "openrouter",
|
||||
"model": "test/model-1",
|
||||
"confirm_expensive_model": True,
|
||||
"profile": "worker_beta",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
worker_cfg = _cfg(isolated_profiles["worker_beta"])
|
||||
model_cfg = worker_cfg.get("model", {})
|
||||
assert isinstance(model_cfg, dict)
|
||||
assert model_cfg.get("provider") == "openrouter"
|
||||
default_model = _cfg(isolated_profiles["default"]).get("model", {})
|
||||
if isinstance(default_model, dict):
|
||||
assert default_model.get("default") != "test/model-1"
|
||||
|
||||
def test_auxiliary_read_scoped_matches_write_target(
|
||||
self, client, isolated_profiles
|
||||
):
|
||||
"""Reads and writes must scope symmetrically: an aux pin written to
|
||||
the worker profile must show up ONLY in the worker-scoped read.
|
||||
(Regression: /api/model/auxiliary used to read unscoped while
|
||||
/api/model/set wrote scoped — the Models page displayed the
|
||||
dashboard profile's pins while editing the selected profile's.)"""
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"auxiliary:\n vision:\n provider: openrouter\n"
|
||||
" model: worker/vision-pin\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
resp = client.get("/api/model/auxiliary", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
|
||||
assert vision["model"] == "worker/vision-pin"
|
||||
|
||||
# Unscoped read = the dashboard's own profile, which has no pin.
|
||||
resp = client.get("/api/model/auxiliary")
|
||||
assert resp.status_code == 200
|
||||
vision = next(t for t in resp.json()["tasks"] if t["task"] == "vision")
|
||||
assert vision["model"] != "worker/vision-pin"
|
||||
|
||||
def test_auxiliary_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/model/auxiliary", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_model_options_scoped_to_profile(self, client, isolated_profiles):
|
||||
"""The Models picker must read the SAME profile model/set writes —
|
||||
current model/provider in the payload come from the scoped config."""
|
||||
(isolated_profiles["worker_beta"] / "config.yaml").write_text(
|
||||
"model:\n provider: openrouter\n default: worker/current-pin\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
resp = client.get("/api/model/options", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# The payload carries the current selection somewhere stable; assert
|
||||
# the worker pin appears in the scoped response and not the unscoped.
|
||||
assert "worker/current-pin" in resp.text
|
||||
resp = client.get("/api/model/options")
|
||||
assert resp.status_code == 200
|
||||
assert "worker/current-pin" not in resp.text
|
||||
assert isinstance(body, dict)
|
||||
|
||||
def test_model_options_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/model/options", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_model_info_unknown_profile_404(self, client, isolated_profiles):
|
||||
"""Regression: the broad except used to convert the 404 into a 200
|
||||
with empty model info ("no model set" — silently wrong)."""
|
||||
resp = client.get("/api/model/info", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_mcp_catalog_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/mcp/catalog", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestProfileScopedPostSetup:
|
||||
def test_post_setup_spawns_with_profile_flag(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
"""Post-setup runs in a -p scoped subprocess so hooks that read
|
||||
config / write per-profile state see the same HERMES_HOME the rest
|
||||
of the drawer's writes targeted."""
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser", "profile": "worker_beta"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [
|
||||
["-p", "worker_beta", "tools", "post-setup", "agent_browser"]
|
||||
]
|
||||
|
||||
def test_post_setup_without_profile_keeps_legacy_argv(
|
||||
self, client, isolated_profiles, monkeypatch
|
||||
):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 777
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_server,
|
||||
"_spawn_hermes_action",
|
||||
lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.tools_config.valid_post_setup_keys",
|
||||
lambda: {"agent_browser"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls == [["tools", "post-setup", "agent_browser"]]
|
||||
|
||||
|
||||
class TestProfileScopedChatPty:
|
||||
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv(profile="worker_beta")
|
||||
assert env is not None
|
||||
assert env["HERMES_HOME"] == str(isolated_profiles["worker_beta"])
|
||||
# Scoped chat must NOT attach to the dashboard's in-memory gateway.
|
||||
assert "HERMES_TUI_GATEWAY_URL" not in env
|
||||
|
||||
def test_chat_argv_unscoped_keeps_legacy_env(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
argv, cwd, env = web_server._resolve_chat_argv()
|
||||
assert env is not None
|
||||
assert env.get("HERMES_HOME") != str(isolated_profiles["worker_beta"])
|
||||
|
||||
def test_chat_argv_unknown_profile_raises(self, isolated_profiles, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.main._make_tui_argv",
|
||||
lambda root, tui_dev=False: (["cat"], None),
|
||||
raising=False,
|
||||
)
|
||||
# Reuse the HTTPException class web_server itself raises — avoids a
|
||||
# direct fastapi import (unresolvable in the ty lint environment).
|
||||
with pytest.raises(web_server.HTTPException) as exc:
|
||||
web_server._resolve_chat_argv(profile="ghost")
|
||||
assert exc.value.status_code == 404
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Tests for the dashboard skill editor endpoints and cron skill attachment.
|
||||
|
||||
The Skills page can now create/edit custom skills (SKILL.md) and the Cron
|
||||
page can attach skills to jobs — closing the "SSH + nano is the only way"
|
||||
gap for headless/VPS users. These tests pin:
|
||||
|
||||
- GET /api/skills/content returns raw SKILL.md (and profile-scopes).
|
||||
- POST /api/skills creates a skill through the same validated write path
|
||||
as the agent's ``skill_manage`` tool (frontmatter validation enforced).
|
||||
- PUT /api/skills/content rewrites an existing SKILL.md (404 on unknown).
|
||||
- POST /api/cron/jobs accepts ``skills`` and persists it on the job;
|
||||
PUT /api/cron/jobs/{id} can update the list.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
SKILL_MD = """---
|
||||
name: {name}
|
||||
description: a test skill
|
||||
---
|
||||
|
||||
# {name}
|
||||
|
||||
Do the thing.
|
||||
"""
|
||||
|
||||
|
||||
def _write_skill(skills_dir, name):
|
||||
d = skills_dir / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(SKILL_MD.format(name=name), encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home):
|
||||
"""Isolated default home + one named profile, each with its own skills."""
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = get_hermes_home()
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_alpha"
|
||||
for home in (default_home, worker_home):
|
||||
(home / "skills").mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
_write_skill(default_home / "skills", "dashboard-skill")
|
||||
_write_skill(worker_home / "skills", "worker-skill")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_alpha": worker_home}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, isolated_profiles):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
class TestSkillContent:
|
||||
def test_get_content_returns_raw_skill_md(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills/content", params={"name": "dashboard-skill"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "dashboard-skill"
|
||||
assert data["content"].startswith("---")
|
||||
assert "Do the thing." in data["content"]
|
||||
|
||||
def test_get_content_scopes_to_profile(self, client, isolated_profiles):
|
||||
resp = client.get(
|
||||
"/api/skills/content",
|
||||
params={"name": "worker-skill", "profile": "worker_alpha"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# ...and the worker skill is invisible without the profile param.
|
||||
resp = client.get("/api/skills/content", params={"name": "worker-skill"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_content_unknown_skill_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/skills/content", params={"name": "nope"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestSkillCreate:
|
||||
def test_create_writes_skill_md(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "my-new-skill", "content": SKILL_MD.format(name="my-new-skill")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
skill_md = isolated_profiles["default"] / "skills" / "my-new-skill" / "SKILL.md"
|
||||
assert skill_md.exists()
|
||||
assert "Do the thing." in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
def test_create_with_category(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "cat-skill",
|
||||
"category": "devops",
|
||||
"content": SKILL_MD.format(name="cat-skill"),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
isolated_profiles["default"] / "skills" / "devops" / "cat-skill" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
def test_create_scopes_to_profile(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "worker-new",
|
||||
"content": SKILL_MD.format(name="worker-new"),
|
||||
"profile": "worker_alpha",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert (
|
||||
isolated_profiles["worker_alpha"] / "skills" / "worker-new" / "SKILL.md"
|
||||
).exists()
|
||||
# Dashboard's own skills dir stays clean.
|
||||
assert not (
|
||||
isolated_profiles["default"] / "skills" / "worker-new"
|
||||
).exists()
|
||||
|
||||
def test_create_rejects_missing_frontmatter(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "bad-skill", "content": "no frontmatter here"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "frontmatter" in resp.json()["detail"].lower()
|
||||
assert not (isolated_profiles["default"] / "skills" / "bad-skill").exists()
|
||||
|
||||
def test_create_rejects_duplicate_name(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={
|
||||
"name": "dashboard-skill",
|
||||
"content": SKILL_MD.format(name="dashboard-skill"),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "already exists" in resp.json()["detail"]
|
||||
|
||||
def test_create_rejects_invalid_name(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/skills",
|
||||
json={"name": "../escape", "content": SKILL_MD.format(name="x")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestSkillUpdate:
|
||||
def test_update_rewrites_skill_md(self, client, isolated_profiles):
|
||||
new_content = SKILL_MD.format(name="dashboard-skill").replace(
|
||||
"Do the thing.", "Do the NEW thing."
|
||||
)
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "dashboard-skill", "content": new_content},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
skill_md = (
|
||||
isolated_profiles["default"] / "skills" / "dashboard-skill" / "SKILL.md"
|
||||
)
|
||||
assert "Do the NEW thing." in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
def test_update_unknown_skill_404(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "nope", "content": SKILL_MD.format(name="nope")},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_invalid_frontmatter_400(self, client, isolated_profiles):
|
||||
resp = client.put(
|
||||
"/api/skills/content",
|
||||
json={"name": "dashboard-skill", "content": "broken"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestEditorEndpointsAuth:
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,kwargs",
|
||||
[
|
||||
("get", "/api/skills/content?name=dashboard-skill", {}),
|
||||
("post", "/api/skills", {"json": {"name": "x", "content": "y"}}),
|
||||
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
|
||||
],
|
||||
)
|
||||
def test_endpoints_401_without_token(
|
||||
self, client, isolated_profiles, method, path, kwargs
|
||||
):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
|
||||
client.headers.pop(_SESSION_HEADER_NAME, None)
|
||||
resp = getattr(client, method)(path, **kwargs)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestCronJobSkills:
|
||||
def test_create_job_with_skills(self, client, isolated_profiles):
|
||||
resp = client.post(
|
||||
"/api/cron/jobs",
|
||||
json={
|
||||
"prompt": "do work",
|
||||
"schedule": "every 1h",
|
||||
"name": "skilled-job",
|
||||
"skills": ["dashboard-skill"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert job["skills"] == ["dashboard-skill"]
|
||||
|
||||
# Round-trip: the list endpoint carries the skills field too.
|
||||
listed = client.get("/api/cron/jobs", params={"profile": "default"}).json()
|
||||
match = [j for j in listed if j["id"] == job["id"]]
|
||||
assert match and match[0]["skills"] == ["dashboard-skill"]
|
||||
|
||||
def test_update_job_skills(self, client, isolated_profiles):
|
||||
job = client.post(
|
||||
"/api/cron/jobs",
|
||||
json={"prompt": "do work", "schedule": "every 1h"},
|
||||
).json()
|
||||
assert job.get("skills") in (None, [])
|
||||
|
||||
resp = client.put(
|
||||
f"/api/cron/jobs/{job['id']}",
|
||||
json={"updates": {"skills": ["dashboard-skill", "worker-skill"]}},
|
||||
params={"profile": "default"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["skills"] == ["dashboard-skill", "worker-skill"]
|
||||
|
||||
# Clearing works too.
|
||||
resp = client.put(
|
||||
f"/api/cron/jobs/{job['id']}",
|
||||
json={"updates": {"skills": []}},
|
||||
params={"profile": "default"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["skills"] == []
|
||||
@@ -142,6 +142,11 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
|
||||
def test_npm_install_uses_workspace_web_scope(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
# Real workspace checkout: the single lockfile lives at the root, so
|
||||
# _workspace_root(web_dir) resolves to the parent and --workspace web
|
||||
# scopes the install. (Without a root lockfile, web_dir IS the root and
|
||||
# --workspace would be dropped — see test below and #42973.)
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
@@ -153,6 +158,36 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
assert "--workspace" in install_cmd
|
||||
assert install_cmd[install_cmd.index("--workspace") + 1] == "web"
|
||||
|
||||
def test_web_install_omits_workspace_when_web_has_own_lockfile(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""web/ with its own lockfile => _workspace_root returns web_dir, so
|
||||
--workspace web would fail (npm can't find that workspace from inside
|
||||
web/). The flag must be dropped and the install run plainly from web_dir.
|
||||
Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.
|
||||
|
||||
With web's own lockfile present at cwd, _run_npm_install_deterministic
|
||||
uses ``npm ci`` (not ``npm install``).
|
||||
"""
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
|
||||
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", return_value=install_cp) as mock_run, \
|
||||
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
args, kwargs = mock_run.call_args
|
||||
assert "--workspace" not in args[0]
|
||||
assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
|
||||
assert kwargs["cwd"] == web_dir
|
||||
|
||||
def test_web_build_uses_idle_timeout_helper(self, tmp_path):
|
||||
"""npm run build now goes through _run_with_idle_timeout (issue #33788).
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _compression_pair(db: SessionDB):
|
||||
base = time.time() - 100
|
||||
db.create_session("root", source="cli")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
|
||||
(base, base + 10),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
|
||||
(base + 20,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_archiving_compression_tip_archives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
|
||||
assert db.set_session_archived("tip", True) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 1
|
||||
assert db.get_session("tip")["archived"] == 1
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == []
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"]
|
||||
|
||||
|
||||
def test_unarchiving_compression_tip_unarchives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
db.set_session_archived("tip", True)
|
||||
|
||||
assert db.set_session_archived("tip", False) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 0
|
||||
assert db.get_session("tip")["archived"] == 0
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for empty-session hygiene — gemini-cli#27770 port.
|
||||
|
||||
Starting the CLI and immediately quitting (or rotating sessions with /new)
|
||||
used to leave empty untitled rows in the session DB that clutter /resume
|
||||
and `hermes sessions list`. ``SessionDB.delete_session_if_empty`` removes
|
||||
a just-ended session row only when it never gained resumable content:
|
||||
no messages, no title, and no child sessions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
session_db = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield session_db
|
||||
session_db.close()
|
||||
|
||||
|
||||
class TestDeleteSessionIfEmpty:
|
||||
def test_deletes_empty_untitled_session(self, db):
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("empty") is True
|
||||
assert db.get_session("empty") is None
|
||||
|
||||
def test_keeps_session_with_messages(self, db):
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hello")
|
||||
db.end_session("busy", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("busy") is False
|
||||
assert db.get_session("busy") is not None
|
||||
|
||||
def test_keeps_titled_session(self, db):
|
||||
"""A user-assigned title is resumable content even without messages."""
|
||||
db.create_session(session_id="titled", source="cli", model="test")
|
||||
db.set_session_title("titled", "Important plans")
|
||||
db.end_session("titled", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("titled") is False
|
||||
assert db.get_session("titled") is not None
|
||||
|
||||
def test_keeps_session_with_children(self, db):
|
||||
"""A parent that spawned delegate subagent runs is not empty."""
|
||||
db.create_session(session_id="parent", source="cli", model="test")
|
||||
db.create_session(
|
||||
session_id="child",
|
||||
source="tool",
|
||||
model="test",
|
||||
parent_session_id="parent",
|
||||
)
|
||||
db.end_session("parent", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("parent") is False
|
||||
assert db.get_session("parent") is not None
|
||||
assert db.get_session("child") is not None
|
||||
|
||||
def test_unknown_session_returns_false(self, db):
|
||||
assert db.delete_session_if_empty("nope") is False
|
||||
|
||||
def test_removes_on_disk_transcripts(self, db, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
(sessions_dir / "empty.json").write_text("{}", encoding="utf-8")
|
||||
(sessions_dir / "empty.jsonl").write_text("", encoding="utf-8")
|
||||
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
assert db.delete_session_if_empty("empty", sessions_dir=sessions_dir)
|
||||
assert not (sessions_dir / "empty.json").exists()
|
||||
assert not (sessions_dir / "empty.jsonl").exists()
|
||||
|
||||
def test_no_file_cleanup_when_kept(self, db, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
(sessions_dir / "busy.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hello")
|
||||
|
||||
assert not db.delete_session_if_empty("busy", sessions_dir=sessions_dir)
|
||||
assert (sessions_dir / "busy.json").exists()
|
||||
|
||||
def test_empty_session_disappears_from_listing(self, db):
|
||||
"""The user-facing symptom: empty rows polluting session lists."""
|
||||
db.create_session(session_id="real", source="cli", model="test")
|
||||
db.append_message("real", role="user", content="do the thing")
|
||||
db.end_session("real", "cli_close")
|
||||
|
||||
db.create_session(session_id="ghost", source="cli", model="test")
|
||||
db.end_session("ghost", "cli_close")
|
||||
|
||||
ids_before = {s["id"] for s in db.list_sessions_rich(source="cli")}
|
||||
assert {"real", "ghost"} <= ids_before
|
||||
|
||||
db.delete_session_if_empty("ghost")
|
||||
|
||||
ids_after = {s["id"] for s in db.list_sessions_rich(source="cli")}
|
||||
assert "real" in ids_after
|
||||
assert "ghost" not in ids_after
|
||||
|
||||
|
||||
class TestCLIDiscardSessionIfEmpty:
|
||||
"""Wiring tests for HermesCLI._discard_session_if_empty."""
|
||||
|
||||
def _make_cli(self, db):
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._session_db = db
|
||||
cli.conversation_history = []
|
||||
return cli
|
||||
|
||||
def test_discards_empty(self, db):
|
||||
db.create_session(session_id="empty", source="cli", model="test")
|
||||
db.end_session("empty", "cli_close")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty("empty") is True
|
||||
assert db.get_session("empty") is None
|
||||
|
||||
def test_keeps_nonempty(self, db):
|
||||
db.create_session(session_id="busy", source="cli", model="test")
|
||||
db.append_message("busy", role="user", content="hi")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty("busy") is False
|
||||
assert db.get_session("busy") is not None
|
||||
|
||||
def test_no_db_is_noop(self):
|
||||
cli = self._make_cli(None)
|
||||
assert cli._discard_session_if_empty("anything") is False
|
||||
|
||||
def test_none_session_id_is_noop(self, db):
|
||||
cli = self._make_cli(db)
|
||||
assert cli._discard_session_if_empty(None) is False
|
||||
|
||||
def test_db_error_swallowed(self, db):
|
||||
class Boom:
|
||||
def delete_session_if_empty(self, *a, **k):
|
||||
raise RuntimeError("locked")
|
||||
|
||||
cli = self._make_cli(Boom())
|
||||
assert cli._discard_session_if_empty("x") is False
|
||||
|
||||
def test_in_memory_history_blocks_prune(self, db):
|
||||
"""The live transcript is authoritative: even if the DB row has no
|
||||
flushed messages yet, a CLI holding conversation history must not
|
||||
prune the session (covers flush-failed / not-yet-flushed turns)."""
|
||||
db.create_session(session_id="unflushed", source="cli", model="test")
|
||||
db.end_session("unflushed", "new_session")
|
||||
|
||||
cli = self._make_cli(db)
|
||||
cli.conversation_history = [{"role": "user", "content": "hello"}]
|
||||
assert cli._discard_session_if_empty("unflushed") is False
|
||||
assert db.get_session("unflushed") is not None
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Regression tests for HERMES_HOME override propagation onto the MCP loop.
|
||||
|
||||
Tasks scheduled via run_coroutine_threadsafe are created inside the MCP
|
||||
event-loop thread, so they copy THAT thread's context — not the scheduling
|
||||
thread's. A per-request profile scope (dashboard ?profile= endpoints, e.g.
|
||||
the MCP "Test server" probe) would silently vanish for anything resolving
|
||||
get_hermes_home() inside the coroutine, most visibly OAuth token-store
|
||||
paths. _run_on_mcp_loop now wraps scheduled coroutines with the caller's
|
||||
override (mcp_tool._wrap_with_home_override).
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_loop():
|
||||
import tools.mcp_tool as mcp_tool
|
||||
|
||||
mcp_tool._ensure_mcp_loop()
|
||||
yield mcp_tool
|
||||
mcp_tool._stop_mcp_loop()
|
||||
|
||||
|
||||
def test_override_propagates_to_mcp_loop(tmp_path, monkeypatch, mcp_loop):
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
profile_home = tmp_path / "profile-home"
|
||||
process_home.mkdir()
|
||||
profile_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def read_home():
|
||||
return str(get_hermes_home())
|
||||
|
||||
# Unscoped: the loop task sees the process home.
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(process_home)
|
||||
|
||||
# Scoped: the caller's override must reach the loop task.
|
||||
token = set_hermes_home_override(str(profile_home))
|
||||
try:
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(profile_home)
|
||||
# Factory form must be wrapped too.
|
||||
assert mcp_loop._run_on_mcp_loop(lambda: read_home(), timeout=10) == str(
|
||||
profile_home
|
||||
)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
# The loop thread's default context is untouched afterwards.
|
||||
assert mcp_loop._run_on_mcp_loop(read_home(), timeout=10) == str(process_home)
|
||||
|
||||
|
||||
def test_oauth_token_paths_follow_override(tmp_path, monkeypatch, mcp_loop):
|
||||
"""The actual symptom path: HermesTokenStorage resolving inside the
|
||||
probe's MCP-loop coroutine must land in the selected profile's
|
||||
mcp-tokens dir, not the process home's."""
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
profile_home = tmp_path / "profile-home"
|
||||
process_home.mkdir()
|
||||
profile_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def token_path():
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
|
||||
return str(HermesTokenStorage("probe-srv")._tokens_path())
|
||||
|
||||
token = set_hermes_home_override(str(profile_home))
|
||||
try:
|
||||
path = mcp_loop._run_on_mcp_loop(token_path(), timeout=10)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert path.startswith(str(profile_home))
|
||||
assert os.path.join("mcp-tokens", "probe-srv.json") in path
|
||||
|
||||
|
||||
def test_concurrent_scopes_do_not_interfere(tmp_path, monkeypatch, mcp_loop):
|
||||
"""Two threads carrying DIFFERENT overrides scheduling onto the same
|
||||
loop must each see their own home — the wrapper is task-local."""
|
||||
import threading
|
||||
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
process_home = tmp_path / "proc-home"
|
||||
home_a = tmp_path / "profile-a"
|
||||
home_b = tmp_path / "profile-b"
|
||||
for h in (process_home, home_a, home_b):
|
||||
h.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(process_home))
|
||||
|
||||
async def read_home():
|
||||
return str(get_hermes_home())
|
||||
|
||||
results: dict = {}
|
||||
|
||||
def scoped_call(key, home):
|
||||
token = set_hermes_home_override(str(home))
|
||||
try:
|
||||
results[key] = mcp_loop._run_on_mcp_loop(read_home(), timeout=10)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=scoped_call, args=("a", home_a)),
|
||||
threading.Thread(target=scoped_call, args=("b", home_b)),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=15)
|
||||
|
||||
assert results == {"a": str(home_a), "b": str(home_b)}
|
||||
|
||||
|
||||
def test_wrap_is_noop_without_override(mcp_loop):
|
||||
"""No active override → the coroutine passes through unwrapped."""
|
||||
|
||||
async def trivial():
|
||||
return 42
|
||||
|
||||
coro = trivial()
|
||||
wrapped = mcp_loop._wrap_with_home_override(coro)
|
||||
assert wrapped is coro
|
||||
coro.close()
|
||||
+45
-1
@@ -90,7 +90,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Coroutine, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -2460,6 +2460,37 @@ def _ensure_mcp_loop():
|
||||
_mcp_thread.start()
|
||||
|
||||
|
||||
def _wrap_with_home_override(coro: "Coroutine") -> "Coroutine":
|
||||
"""Carry the caller's context-local HERMES_HOME override into ``coro``.
|
||||
|
||||
Returns ``coro`` unchanged when no override is active. Otherwise wraps
|
||||
it so the override is set inside the coroutine's own (task-local)
|
||||
context on the MCP loop and reset when it completes — concurrent calls
|
||||
carrying different scopes don't interfere.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import (
|
||||
get_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
home_override = get_hermes_home_override()
|
||||
except Exception:
|
||||
return coro
|
||||
if not home_override:
|
||||
return coro
|
||||
|
||||
async def _scoped():
|
||||
token = set_hermes_home_override(home_override)
|
||||
try:
|
||||
return await coro
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
return _scoped()
|
||||
|
||||
|
||||
def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
"""Schedule a coroutine on the MCP event loop and block until done.
|
||||
|
||||
@@ -2482,6 +2513,19 @@ def _run_on_mcp_loop(coro_or_factory, timeout: float = 30):
|
||||
raise RuntimeError("MCP event loop is not running")
|
||||
|
||||
coro = coro_or_factory() if callable(coro_or_factory) else coro_or_factory
|
||||
|
||||
# Propagate the context-local HERMES_HOME override onto the MCP loop.
|
||||
# Tasks scheduled via run_coroutine_threadsafe are created INSIDE the
|
||||
# loop thread, so they copy the loop thread's context — not the
|
||||
# scheduling thread's. A per-request profile scope (the dashboard's
|
||||
# ?profile= endpoints, e.g. the MCP "Test server" probe) would silently
|
||||
# vanish here: OAuth token stores and any other get_hermes_home()
|
||||
# resolution inside the coroutine would read the process home instead
|
||||
# of the selected profile's. Re-establish the override inside the
|
||||
# task's own context (task-local — concurrent calls carrying different
|
||||
# scopes don't interfere). No-op when no override is active.
|
||||
coro = _wrap_with_home_override(coro)
|
||||
|
||||
future = safe_schedule_threadsafe(
|
||||
coro, loop,
|
||||
logger=logger,
|
||||
|
||||
+27
@@ -339,6 +339,33 @@ TOOLSETS = {
|
||||
"tools": [],
|
||||
"includes": ["web", "vision", "image_gen"]
|
||||
},
|
||||
|
||||
# Coding posture (base Hermes — CLI/TUI/desktop/ACP). Auto-selected in a
|
||||
# code workspace; see agent/coding_context.py. Keeps everything you reach
|
||||
# for while pairing on code and drops the rest (messaging, tts, image_gen,
|
||||
# spotify, home-assistant, cron, computer-use).
|
||||
"coding": {
|
||||
"description": "Coding-focused toolset: files, terminal, search, web docs, skills, todo, delegate, vision, browser",
|
||||
"tools": [
|
||||
"web_search", "web_extract",
|
||||
"terminal", "process", "read_terminal",
|
||||
"read_file", "write_file", "patch", "search_files",
|
||||
"vision_analyze",
|
||||
"skills_list", "skill_view", "skill_manage",
|
||||
"browser_navigate", "browser_snapshot", "browser_click",
|
||||
"browser_type", "browser_scroll", "browser_back",
|
||||
"browser_press", "browser_get_images",
|
||||
"browser_vision", "browser_console", "browser_cdp", "browser_dialog",
|
||||
"todo", "memory",
|
||||
"session_search", "clarify",
|
||||
"execute_code", "delegate_task",
|
||||
],
|
||||
"includes": [],
|
||||
# Posture toolset: selected per-session by agent/coding_context.py,
|
||||
# never auto-recovered into per-platform tool config (see the
|
||||
# non-configurable-toolset recovery loop in hermes_cli/tools_config.py).
|
||||
"posture": True,
|
||||
},
|
||||
|
||||
# ==========================================================================
|
||||
# Full Hermes toolsets (CLI + messaging platforms)
|
||||
|
||||
@@ -1680,6 +1680,22 @@ def _load_enabled_toolsets() -> list[str] | None:
|
||||
cfg = None
|
||||
fallback_notice = None
|
||||
|
||||
# Coding posture (base Hermes): with no explicit pin, collapse to the
|
||||
# coding toolset (+ enabled MCP servers) when sitting in a code workspace.
|
||||
# The desktop app and `hermes --tui` both land here. See
|
||||
# agent/coding_context.py. No config is loaded yet at this point, so we let
|
||||
# coding_selection() load it lazily (cli.py passes its already-resolved
|
||||
# CLI_CONFIG instead, purely to avoid a redundant read).
|
||||
if not explicit:
|
||||
try:
|
||||
from agent.coding_context import coding_selection
|
||||
|
||||
selection = coding_selection(platform="tui")
|
||||
if selection is not None:
|
||||
return selection
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from toolsets import validate_toolset
|
||||
except Exception:
|
||||
|
||||
@@ -260,3 +260,71 @@ describe('StatusRule credits notice render priority', () => {
|
||||
expect(textContent(element)).toContain('opus 4.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule idle-since read-out', () => {
|
||||
// The IdleSince component uses hooks, so it can't be invoked outside a
|
||||
// renderer — assert on the element tree instead (same reason the duration
|
||||
// tests don't check SessionDuration's text).
|
||||
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findComponentByName(child, name)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof node.type === 'function' && node.type.name === name) {
|
||||
return node
|
||||
}
|
||||
|
||||
return findComponentByName(node.props.children, name)
|
||||
}
|
||||
|
||||
it('shows time since the last final agent response when idle', () => {
|
||||
const endedAt = Date.now() - 42_000
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: endedAt,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
const idle = findComponentByName(element, 'IdleSince')
|
||||
|
||||
expect(idle).not.toBeNull()
|
||||
expect(idle!.props.endedAt).toBe(endedAt)
|
||||
})
|
||||
|
||||
it('is hidden while a turn is busy', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
lastTurnEndedAt: Date.now() - 42_000,
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
|
||||
it('is hidden before the first turn completes', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: null,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,6 +368,7 @@ export interface AppLayoutProgressProps {
|
||||
export interface AppLayoutStatusProps {
|
||||
cwdLabel: string
|
||||
goodVibesTick: number
|
||||
lastTurnEndedAt: null | number
|
||||
sessionStartedAt: null | number
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
|
||||
@@ -173,6 +173,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
|
||||
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
|
||||
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
|
||||
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(null)
|
||||
const [goodVibesTick, setGoodVibesTick] = useState(0)
|
||||
const [bellOnComplete, setBellOnComplete] = useState(false)
|
||||
|
||||
@@ -500,10 +501,14 @@ export function useMainApp(gw: GatewayClient) {
|
||||
useEffect(() => {
|
||||
if (ui.busy) {
|
||||
setTurnStartedAt(prev => prev ?? Date.now())
|
||||
} else {
|
||||
} else if (turnStartedAt != null) {
|
||||
// Only stamp the idle marker when a turn was actually live — busy is
|
||||
// also false on mount and we don't want a phantom "done" timestamp
|
||||
// before the first turn has completed.
|
||||
setLastTurnEndedAt(Date.now())
|
||||
setTurnStartedAt(null)
|
||||
}
|
||||
}, [ui.busy])
|
||||
}, [ui.busy, turnStartedAt])
|
||||
|
||||
useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid })
|
||||
|
||||
@@ -1090,6 +1095,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
// essentials and truncates this further on narrow terminals.
|
||||
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
|
||||
goodVibesTick,
|
||||
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
|
||||
sessionStartedAt: ui.sid ? sessionStartedAt : null,
|
||||
showStickyPrompt: !!stickyPrompt,
|
||||
statusColor: statusColorOf(ui.status, ui.theme.color),
|
||||
@@ -1103,6 +1109,7 @@ export function useMainApp(gw: GatewayClient) {
|
||||
cwd,
|
||||
gitBranch,
|
||||
goodVibesTick,
|
||||
lastTurnEndedAt,
|
||||
sessionStartedAt,
|
||||
stickyPrompt,
|
||||
turnStartedAt,
|
||||
|
||||
@@ -341,6 +341,21 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||
return fmtDuration(now - startedAt)
|
||||
}
|
||||
|
||||
function IdleSince({ endedAt }: { endedAt: number }) {
|
||||
// Time since the last final agent response. Re-ticks every second like
|
||||
// SessionDuration so the read-out stays live while the session idles.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now())
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [endedAt])
|
||||
|
||||
return `✓ ${fmtDuration(now - endedAt)}`
|
||||
}
|
||||
|
||||
const effortLabel = (effort?: string) => {
|
||||
const value = String(effort ?? '')
|
||||
.trim()
|
||||
@@ -400,6 +415,7 @@ export function StatusRule({
|
||||
notice,
|
||||
usage,
|
||||
bgCount,
|
||||
lastTurnEndedAt,
|
||||
liveSessionCount,
|
||||
sessionStartedAt,
|
||||
showCost,
|
||||
@@ -488,6 +504,10 @@ export function StatusRule({
|
||||
|
||||
const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`))
|
||||
const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH)
|
||||
// Idle clock — time since the last final agent response. Hidden while busy
|
||||
// (the FaceTicker's elapsed tail covers the live turn) and before the first
|
||||
// turn completes. Shares the duration breakpoint and width reservation.
|
||||
const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH)
|
||||
const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`))
|
||||
const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel))
|
||||
const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText))
|
||||
@@ -567,6 +587,12 @@ export function StatusRule({
|
||||
<SessionDuration startedAt={sessionStartedAt!} />
|
||||
</Text>
|
||||
) : null}
|
||||
{showIdle ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
<IdleSince endedAt={lastTurnEndedAt!} />
|
||||
</Text>
|
||||
) : null}
|
||||
{showCompressions ? (
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{' │ '}
|
||||
@@ -725,6 +751,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
|
||||
|
||||
interface StatusRuleProps {
|
||||
bgCount: number
|
||||
lastTurnEndedAt?: null | number
|
||||
liveSessionCount: number
|
||||
busy: boolean
|
||||
cols: number
|
||||
|
||||
@@ -366,6 +366,7 @@ const StatusRulePane = memo(function StatusRulePane({
|
||||
cols={composer.cols}
|
||||
cwdLabel={status.cwdLabel}
|
||||
indicatorStyle={ui.indicatorStyle}
|
||||
lastTurnEndedAt={status.lastTurnEndedAt}
|
||||
liveSessionCount={ui.liveSessionCount}
|
||||
model={ui.info?.model ?? ''}
|
||||
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
|
||||
|
||||
+37
-11
@@ -64,6 +64,10 @@ import { useBelowBreakpoint } from "@nous-research/ui/hooks/use-below-breakpoint
|
||||
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
|
||||
import { AuthWidget } from "@/components/AuthWidget";
|
||||
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
|
||||
import { ProfileProvider } from "@/contexts/ProfileProvider";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { ProfileSwitcher } from "@/components/ProfileSwitcher";
|
||||
import { ProfileScopeBanner } from "@/components/ProfileScopeBanner";
|
||||
import { useSystemActions } from "@/contexts/useSystemActions";
|
||||
import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
@@ -474,6 +478,7 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProfileProvider>
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black text-text-primary antialiased"
|
||||
@@ -528,6 +533,7 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<PluginSlot name="header-banner" />
|
||||
<ProfileScopeBanner />
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
@@ -602,6 +608,8 @@ export default function App() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ProfileSwitcher collapsed={isDesktopCollapsed} />
|
||||
|
||||
<nav
|
||||
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
|
||||
aria-label={t.app.navigation}
|
||||
@@ -727,17 +735,19 @@ export default function App() {
|
||||
"min-h-0 flex flex-1 flex-col",
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<ProfileKeyedRoutes>
|
||||
<Routes>
|
||||
{routes.map(({ key, path, element }) => (
|
||||
<Route key={key} path={path} element={element} />
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<UnknownRouteFallback pluginsLoading={pluginsLoading} />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ProfileKeyedRoutes>
|
||||
|
||||
{embeddedChat &&
|
||||
!chatOverriddenByPlugin &&
|
||||
@@ -775,9 +785,25 @@ export default function App() {
|
||||
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
</ProfileProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remounts the entire routed page tree when the global management profile
|
||||
* changes. Pages load their data on mount; without this, a page opened
|
||||
* under profile A would keep showing A's state while writes (via the
|
||||
* fetchJSON ?profile= injection) silently targeted the newly selected
|
||||
* profile B — the exact stale-target footgun the switcher exists to kill.
|
||||
* Keying by profile resets every page's local state so it refetches under
|
||||
* the new scope. The persistent ChatPage host below handles its own
|
||||
* remount (channel keyed on scopedProfile).
|
||||
*/
|
||||
function ProfileKeyedRoutes({ children }: { children: ReactNode }) {
|
||||
const { profile } = useProfileScope();
|
||||
return <div key={profile || "__own__"} className="contents">{children}</div>;
|
||||
}
|
||||
|
||||
function SidebarNavLink({
|
||||
closeMobile,
|
||||
collapsed,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/**
|
||||
* App-wide amber banner shown while the global switcher targets a profile
|
||||
* OTHER than the dashboard's own — every management write (config, keys,
|
||||
* skills, MCPs, model) and new Chat sessions land in that profile.
|
||||
*/
|
||||
export function ProfileScopeBanner() {
|
||||
const { profile, currentProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!profile || profile === currentProfile) return null;
|
||||
|
||||
return (
|
||||
// mt-14 on mobile clears the fixed lg:hidden header (h-14, z-40) so the
|
||||
// scope banner — the main safety signal for scoped writes — is never
|
||||
// hidden behind it; lg:mt-0 restores desktop flow.
|
||||
<div className="mt-14 lg:mt-0 flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-4 py-1.5 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.app.managingProfileBanner ??
|
||||
"Managing profile “{name}” — config, keys, skills, MCPs, model, and new chats apply to that profile."
|
||||
).replace("{name}", profile)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The machine dashboard's single write-target selector.
|
||||
*
|
||||
* Rendered in the sidebar above the nav. Every management page (Config,
|
||||
* Keys, Skills, MCP, Models) reads/writes the selected profile via the
|
||||
* fetchJSON ?profile= injection. Hidden when only one profile exists.
|
||||
*/
|
||||
export function ProfileSwitcher({ collapsed }: { collapsed?: boolean }) {
|
||||
const { profile, currentProfile, profiles, setProfile } = useProfileScope();
|
||||
const { t } = useI18n();
|
||||
|
||||
if (profiles.length < 2) return null;
|
||||
|
||||
const managed = profile || currentProfile || "default";
|
||||
const isOther = !!profile && profile !== currentProfile;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 border-b border-current/10 px-3 py-2",
|
||||
collapsed && "lg:justify-center lg:px-0",
|
||||
)}
|
||||
title={t.app.managingProfile ?? "Managing profile"}
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0",
|
||||
isOther ? "text-amber-300" : "text-text-tertiary",
|
||||
)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t.app.managingProfile ?? "Managing profile"}
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-none border bg-background px-1 text-xs",
|
||||
isOther
|
||||
? "border-amber-500/50 text-amber-300"
|
||||
: "border-border text-text-secondary",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{(t.app.currentProfileOption ?? "this dashboard ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((name) => name !== currentProfile)
|
||||
.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{collapsed && (
|
||||
<span className="sr-only">{managed}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* SkillEditorDialog — create or edit a SKILL.md from the dashboard */
|
||||
/* */
|
||||
/* Headless/VPS users have no editor besides this: the only other way */
|
||||
/* to author a custom skill is SSH + a terminal editor. Create mode */
|
||||
/* posts a brand-new skill (name + optional category + SKILL.md); */
|
||||
/* edit mode loads the existing SKILL.md raw text and rewrites it. */
|
||||
/* Validation (frontmatter, name, size) happens server-side via the */
|
||||
/* same path the agent's skill_manage tool uses, so errors come back */
|
||||
/* as actionable messages rendered inline. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CREATE_TEMPLATE = `---
|
||||
name: my-skill
|
||||
description: One-line description of when to use this skill.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
Numbered steps, exact commands, and pitfalls go here.
|
||||
`;
|
||||
|
||||
export interface SkillEditorDialogProps {
|
||||
open: boolean;
|
||||
/** Skill name to edit, or null for create mode. */
|
||||
editName: string | null;
|
||||
/** Profile to scope reads/writes to ("" = the dashboard's own profile). */
|
||||
profile?: string;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save so the page can refresh its list. */
|
||||
onSaved: (name: string) => void;
|
||||
}
|
||||
|
||||
export function SkillEditorDialog({
|
||||
open,
|
||||
editName,
|
||||
profile,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: SkillEditorDialogProps) {
|
||||
// The body is remounted via `key` every time the dialog opens or the
|
||||
// target skill changes, so all form state initializes through useState
|
||||
// initializers — no reset-on-open effect (react-hooks/set-state-in-effect).
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{open && (
|
||||
<EditorBody
|
||||
key={editName ?? "__create__"}
|
||||
editName={editName}
|
||||
profile={profile}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBody({
|
||||
editName,
|
||||
profile,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Omit<SkillEditorDialogProps, "open">) {
|
||||
const isEdit = editName !== null;
|
||||
const [name, setName] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [content, setContent] = useState(isEdit ? "" : CREATE_TEMPLATE);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editName) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkillContent(editName, profile || undefined)
|
||||
.then((res) => !cancelled && setContent(res.content))
|
||||
.catch((e) => !cancelled && setError(String(e)))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [editName, profile]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
if (!isEdit && !name.trim()) {
|
||||
setError("Skill name is required.");
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError("SKILL.md content is required.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateSkillContent(editName, content, profile || undefined);
|
||||
onSaved(editName);
|
||||
} else {
|
||||
const trimmed = name.trim();
|
||||
await api.createSkill(
|
||||
{
|
||||
name: trimmed,
|
||||
content,
|
||||
category: category.trim() || undefined,
|
||||
},
|
||||
profile || undefined,
|
||||
);
|
||||
onSaved(trimmed);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? `Edit skill: ${editName}` : "New skill"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Rewrite this skill's SKILL.md. Frontmatter (name, description) is validated on save."
|
||||
: "Author a custom skill — YAML frontmatter plus markdown instructions. It becomes available to the agent and attachable to cron jobs."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{!isEdit && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-name">Name</Label>
|
||||
<Input
|
||||
id="skill-editor-name"
|
||||
autoFocus
|
||||
placeholder="my-skill"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-category">Category (optional)</Label>
|
||||
<Input
|
||||
id="skill-editor-category"
|
||||
placeholder="devops"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="skill-editor-content">SKILL.md</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
id="skill-editor-content"
|
||||
spellCheck={false}
|
||||
className="min-h-[320px] max-h-[55vh] w-full resize-y border border-border bg-background/40 px-3 py-2 font-mono text-xs leading-relaxed shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="whitespace-pre-wrap text-xs text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button ghost size="sm" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
prefix={saving ? <Spinner /> : undefined}
|
||||
>
|
||||
{saving ? "Saving…" : isEdit ? "Save changes" : "Create skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr
|
||||
setPostSetupLog([]);
|
||||
setPostSetupKey(provider.post_setup);
|
||||
try {
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
|
||||
await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile);
|
||||
// Bump the trigger so the poll effect (re)starts tailing the log.
|
||||
setPostSetupTrigger((n) => n + 1);
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { api, setManagementProfile } from "@/lib/api";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
/**
|
||||
* Machine-level management-profile scope.
|
||||
*
|
||||
* One switcher (rendered in the sidebar) decides which profile every
|
||||
* management page reads/writes. React STATE is the source of truth; the
|
||||
* URL (`?profile=<name>`) is a synchronized projection of it so deep links
|
||||
* land scoped and refresh survives. The selection is mirrored into the api
|
||||
* module so `fetchJSON` transparently appends it to the profile-scoped
|
||||
* endpoint families. "" = the dashboard's own profile.
|
||||
*
|
||||
* Why state-first instead of URL-first: sidebar nav links are bare paths
|
||||
* (`/config`, `/skills`). A URL-derived scope would silently reset to the
|
||||
* dashboard's own profile on every nav click — the switcher would LOOK
|
||||
* global while normal navigation dropped the write target. With state as
|
||||
* truth, the effect below re-asserts `?profile=` onto the new location
|
||||
* after each navigation, so the scope survives nav and stays deep-linkable.
|
||||
*
|
||||
* This exists because "Set as active" on the Profiles page only flips the
|
||||
* sticky active_profile file (future CLI/gateway runs) — it cannot retarget
|
||||
* the running dashboard. The switcher is the dashboard's own, visible,
|
||||
* write-target selector.
|
||||
*/
|
||||
export function ProfileProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
const [profiles, setProfiles] = useState<string[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState("default");
|
||||
|
||||
// Initial value comes from the URL (deep link / refresh / unified-launch
|
||||
// preselect); afterwards state leads and the URL follows.
|
||||
const [profile, setProfileState] = useState(
|
||||
() => searchParams.get("profile") ?? "",
|
||||
);
|
||||
|
||||
// Mirror into the api module synchronously on every render where it
|
||||
// changed, so fetches fired by child effects in the same commit see it.
|
||||
setManagementProfile(profile);
|
||||
|
||||
// A profile param arriving via in-app navigation (e.g. the Profiles
|
||||
// page's "Manage skills & tools" linking to /skills?profile=X) must win
|
||||
// over current state — it's an explicit scope request.
|
||||
const urlProfile = searchParams.get("profile");
|
||||
useEffect(() => {
|
||||
if (urlProfile !== null && urlProfile !== profile) {
|
||||
setManagementProfile(urlProfile);
|
||||
setProfileState(urlProfile);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlProfile]);
|
||||
|
||||
// Re-assert ?profile= after navigations that dropped it (bare nav links).
|
||||
// Runs on every pathname/profile change; no-ops when already in sync.
|
||||
useEffect(() => {
|
||||
const inUrl = searchParams.get("profile") ?? "";
|
||||
if ((profile || "") === inUrl) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (profile) next.set("profile", profile);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles.map((p) => p.name)))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const setProfile = useCallback(
|
||||
(name: string) => {
|
||||
setManagementProfile(name);
|
||||
setProfileState(name);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ profile, currentProfile, profiles, setProfile }),
|
||||
[profile, currentProfile, profiles, setProfile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>{children}</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface ProfileContextValue {
|
||||
/** Profile every management surface reads/writes ("" = the dashboard
|
||||
* process's own profile). */
|
||||
profile: string;
|
||||
/** The profile the dashboard process itself runs under. */
|
||||
currentProfile: string;
|
||||
/** Known profile names (includes "default"). */
|
||||
profiles: string[];
|
||||
setProfile: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ProfileContext = createContext<ProfileContextValue>({
|
||||
profile: "",
|
||||
currentProfile: "default",
|
||||
profiles: [],
|
||||
setProfile: () => {},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { ProfileContext } from "@/contexts/profile-context";
|
||||
|
||||
export function useProfileScope() {
|
||||
return useContext(ProfileContext);
|
||||
}
|
||||
@@ -93,6 +93,10 @@ export const en: Translations = {
|
||||
statusOverview: "Status overview",
|
||||
system: "System",
|
||||
webUi: "Web UI",
|
||||
managingProfile: "Managing profile",
|
||||
currentProfileOption: "this dashboard ({name})",
|
||||
managingProfileBanner:
|
||||
"Managing profile \u201c{name}\u201d \u2014 config, keys, skills, MCPs, model, and new chats apply to that profile.",
|
||||
},
|
||||
|
||||
status: {
|
||||
|
||||
@@ -110,6 +110,10 @@ export interface Translations {
|
||||
statusOverview: string;
|
||||
system: string;
|
||||
webUi: string;
|
||||
/** Optional — fall back to English literals until translated. */
|
||||
managingProfile?: string;
|
||||
currentProfileOption?: string;
|
||||
managingProfileBanner?: string;
|
||||
};
|
||||
|
||||
// ── Status page ──
|
||||
|
||||
+92
-6
@@ -41,11 +41,54 @@ function setSessionHeader(headers: Headers, token: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global management-profile scope ──────────────────────────────────
|
||||
// The dashboard is a machine-level management surface: one header switcher
|
||||
// (ProfileProvider in App.tsx) decides which profile the management pages
|
||||
// read/write, and fetchJSON transparently appends ?profile=<name> to the
|
||||
// profile-scoped endpoint families below. "" = the dashboard process's own
|
||||
// profile (legacy behavior). Calls that already carry an explicit profile
|
||||
// (e.g. ProfileBuilder writes) are left untouched — explicit beats global.
|
||||
let _managementProfile = "";
|
||||
|
||||
export function setManagementProfile(name: string): void {
|
||||
_managementProfile = (name || "").trim();
|
||||
}
|
||||
|
||||
export function getManagementProfile(): string {
|
||||
return _managementProfile;
|
||||
}
|
||||
|
||||
// Endpoint families that honor ?profile= on the backend (web_server.py
|
||||
// _profile_scope). Anything else — sessions, analytics, ops, pairing,
|
||||
// channels, cron (which has its own per-job profile params), profiles
|
||||
// themselves — is machine-global or self-scoped and must NOT be rewritten.
|
||||
const PROFILE_SCOPED_PREFIXES = [
|
||||
"/api/skills",
|
||||
"/api/tools/toolsets",
|
||||
"/api/config",
|
||||
"/api/env",
|
||||
"/api/mcp",
|
||||
"/api/model/info",
|
||||
"/api/model/set",
|
||||
"/api/model/auxiliary",
|
||||
"/api/model/options",
|
||||
];
|
||||
|
||||
function withManagementProfile(url: string): string {
|
||||
if (!_managementProfile) return url;
|
||||
if (url.includes("profile=")) return url; // explicit param wins
|
||||
const path = url.split("?")[0];
|
||||
if (!PROFILE_SCOPED_PREFIXES.some((p) => path.startsWith(p))) return url;
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}profile=${encodeURIComponent(_managementProfile)}`;
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
url = withManagementProfile(url);
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
@@ -426,7 +469,7 @@ export const api = {
|
||||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||
getCronDeliveryTargets: () =>
|
||||
fetchJSON<{ targets: CronDeliveryTarget[] }>("/api/cron/delivery-targets"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string; skills?: string[] }, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -436,7 +479,7 @@ export const api = {
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
updateCronJob: (
|
||||
id: string,
|
||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string },
|
||||
updates: { prompt?: string; schedule?: string; name?: string; deliver?: string; skills?: string[] },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(
|
||||
@@ -562,6 +605,22 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, enabled, profile: profile || undefined }),
|
||||
}),
|
||||
getSkillContent: (name: string, profile?: string) =>
|
||||
fetchJSON<SkillContent>(
|
||||
`/api/skills/content?name=${encodeURIComponent(name)}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`,
|
||||
),
|
||||
createSkill: (skill: { name: string; content: string; category?: string }, profile?: string) =>
|
||||
fetchJSON<SkillWriteResult>("/api/skills", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...skill, profile: profile || undefined }),
|
||||
}),
|
||||
updateSkillContent: (name: string, content: string, profile?: string) =>
|
||||
fetchJSON<SkillWriteResult>("/api/skills/content", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, content, profile: profile || undefined }),
|
||||
}),
|
||||
getToolsets: (profile?: string) =>
|
||||
fetchJSON<ToolsetInfo[]>(`/api/tools/toolsets${profileQuery(profile)}`),
|
||||
toggleToolset: (name: string, enabled: boolean, profile?: string) =>
|
||||
@@ -595,13 +654,13 @@ export const api = {
|
||||
body: JSON.stringify({ env, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
runToolsetPostSetup: (name: string, key: string) =>
|
||||
runToolsetPostSetup: (name: string, key: string, profile?: string) =>
|
||||
fetchJSON<ActionResponse & { key: string }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
body: JSON.stringify({ key, profile: profile || undefined }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -866,6 +925,8 @@ export const api = {
|
||||
|
||||
// ── Admin: Webhooks ─────────────────────────────────────────────────
|
||||
getWebhooks: () => fetchJSON<WebhooksResponse>("/api/webhooks"),
|
||||
enableWebhooks: () =>
|
||||
fetchJSON<WebhookEnableResponse>("/api/webhooks/enable", { method: "POST" }),
|
||||
createWebhook: (body: WebhookCreate) =>
|
||||
fetchJSON<WebhookRoute & { secret: string }>("/api/webhooks", {
|
||||
method: "POST",
|
||||
@@ -940,11 +1001,11 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ output }),
|
||||
}),
|
||||
runImport: (archive: string) =>
|
||||
runImport: (archive: string, force = false) =>
|
||||
fetchJSON<ActionResponse>("/api/ops/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ archive }),
|
||||
body: JSON.stringify({ archive, force }),
|
||||
}),
|
||||
getHooks: () => fetchJSON<HooksResponse>("/api/ops/hooks"),
|
||||
createHook: (body: HookCreate) =>
|
||||
@@ -1288,6 +1349,17 @@ export interface WebhooksResponse {
|
||||
subscriptions: WebhookRoute[];
|
||||
}
|
||||
|
||||
export interface WebhookEnableResponse {
|
||||
ok: boolean;
|
||||
platform: "webhook";
|
||||
enabled: true;
|
||||
needs_restart: boolean;
|
||||
restart_started?: boolean;
|
||||
restart_action?: string;
|
||||
restart_pid?: number | null;
|
||||
restart_error?: string;
|
||||
}
|
||||
|
||||
export interface WebhookCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -1735,6 +1807,7 @@ export interface CronJob {
|
||||
name?: string | null;
|
||||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
skills?: string[] | null;
|
||||
schedule?: { kind?: string; expr?: string; display?: string };
|
||||
schedule_display?: string | null;
|
||||
enabled: boolean;
|
||||
@@ -1759,6 +1832,19 @@ export interface SkillInfo {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SkillContent {
|
||||
name: string;
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SkillWriteResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
|
||||
@@ -37,11 +37,13 @@ import { useI18n } from "@/i18n";
|
||||
import { api } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
|
||||
function buildWsUrl(
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
profile: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
@@ -49,6 +51,10 @@ function buildWsUrl(
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (profile) qs.set("profile", profile);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
|
||||
@@ -173,7 +179,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
// treat the current resume target as part of the PTY identity and rebuild the
|
||||
// terminal session when it changes.
|
||||
const resumeParam = searchParams.get("resume");
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam]);
|
||||
// Profile-scoped chat: spawn the PTY under the globally selected
|
||||
// management profile. Changing it remounts the terminal (key below /
|
||||
// effect dep) so the user explicitly starts a fresh scoped session.
|
||||
const { profile: scopedProfile } = useProfileScope();
|
||||
const channel = useMemo(() => generateChannelId(), [resumeParam, scopedProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeParam) return;
|
||||
@@ -576,7 +586,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel);
|
||||
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
@@ -714,7 +724,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
copyResetRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [channel, resumeParam]);
|
||||
}, [channel, resumeParam, scopedProfile]);
|
||||
|
||||
// When the user returns to the chat tab (isActive: false → true), the
|
||||
// terminal host just transitioned from display:none to display:flex.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user