Merge remote-tracking branch 'origin/main' into fix/bundle-size

This commit is contained in:
ethernet
2026-05-11 16:01:00 -04:00
1437 changed files with 219762 additions and 11968 deletions

View File

@@ -0,0 +1,138 @@
"""Quick benchmark: subprocess eval vs supervisor-WS eval.
Runs both paths against the same live Chrome and prints a comparison table.
Not a pytest — a script you run manually for the PR description.
Usage:
.venv/bin/python scripts/benchmark_browser_eval.py [--iterations N]
"""
from __future__ import annotations
import argparse
import shutil
import statistics
import subprocess
import sys
import tempfile
import time
import urllib.request
import json
def _find_chrome() -> str:
for c in ("google-chrome", "chromium", "chromium-browser"):
p = shutil.which(c)
if p:
return p
print("No Chrome binary found.", file=sys.stderr)
sys.exit(1)
def _start_chrome(port: int):
profile = tempfile.mkdtemp(prefix="hermes-bench-eval-")
proc = subprocess.Popen(
[
_find_chrome(),
f"--remote-debugging-port={port}",
f"--user-data-dir={profile}",
"--no-first-run",
"--no-default-browser-check",
"--headless=new",
"--disable-gpu",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/version", timeout=1) as r:
info = json.loads(r.read().decode())
return proc, profile, info["webSocketDebuggerUrl"]
except Exception:
time.sleep(0.25)
proc.terminate()
raise RuntimeError("Chrome didn't expose CDP")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--iterations", type=int, default=50)
parser.add_argument("--port", type=int, default=9333)
args = parser.parse_args()
proc, profile, cdp_url = _start_chrome(args.port)
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY
# Warm up: start the supervisor, navigate to a page.
supervisor = SUPERVISOR_REGISTRY.get_or_start(
task_id="bench-eval", cdp_url=cdp_url
)
# Give it a moment to attach.
time.sleep(1.0)
# Sanity check: one eval over WS should succeed.
sanity = supervisor.evaluate_runtime("1 + 1")
if not sanity.get("ok") or sanity.get("result") != 2:
print(f"sanity check failed: {sanity}", file=sys.stderr)
sys.exit(2)
# ── Bench 1: supervisor WS path ──────────────────────────────────
ws_times: list[float] = []
for _ in range(args.iterations):
t0 = time.monotonic()
out = supervisor.evaluate_runtime("1 + 1")
t1 = time.monotonic()
assert out.get("ok"), out
ws_times.append((t1 - t0) * 1000)
# ── Bench 2: agent-browser subprocess path ────────────────────────
# Skip if agent-browser isn't installed — the WS bench still tells
# us what we need.
if shutil.which("agent-browser") is None and shutil.which("npx") is None:
print("agent-browser CLI not found — skipping subprocess bench.")
sub_times = []
else:
from tools.browser_tool import _run_browser_command, _last_session_key
task_id = _last_session_key("bench-eval")
sub_times = []
for _ in range(args.iterations):
t0 = time.monotonic()
_run_browser_command(task_id, "eval", ["1 + 1"])
t1 = time.monotonic()
sub_times.append((t1 - t0) * 1000)
def fmt(name: str, ts: list[float]) -> str:
if not ts:
return f" {name:<40} (skipped)"
mean = statistics.mean(ts)
median = statistics.median(ts)
mn, mx = min(ts), max(ts)
return (
f" {name:<40} mean={mean:>7.2f}ms median={median:>7.2f}ms "
f"min={mn:>7.2f}ms max={mx:>7.2f}ms"
)
print()
print(f"browser_eval benchmark — {args.iterations} iterations of `1 + 1`")
print("-" * 90)
print(fmt("supervisor WS (Runtime.evaluate)", ws_times))
print(fmt("agent-browser subprocess (eval)", sub_times))
if ws_times and sub_times:
speedup = statistics.mean(sub_times) / statistics.mean(ws_times)
print()
print(f"Speedup: {speedup:.1f}x (mean)")
finally:
SUPERVISOR_REGISTRY.stop_all()
proc.terminate()
try:
proc.wait(timeout=3)
except Exception:
proc.kill()
shutil.rmtree(profile, ignore_errors=True)
if __name__ == "__main__":
main()

View File

@@ -81,7 +81,7 @@ def build_catalog() -> dict:
def main() -> int:
catalog = build_catalog()
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w") as fh:
with open(OUTPUT_PATH, "w", encoding="utf-8") as fh:
json.dump(catalog, fh, indent=2)
fh.write("\n")

View File

@@ -147,7 +147,7 @@ def batch_resolve_paths(skills: list, auth: GitHubAuth) -> list:
4. Match skills to their resolved paths
"""
# Filter to skills.sh entries that need resolution
skills_sh = [s for s in skills if s["source"] in ("skills.sh", "skills-sh")]
skills_sh = [s for s in skills if s["source"] in {"skills.sh", "skills-sh"}]
if not skills_sh:
return skills
@@ -304,7 +304,7 @@ def main():
}
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
with open(OUTPUT_PATH, "w") as f:
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
elapsed = time.time() - overall_start

View File

@@ -0,0 +1,624 @@
#!/usr/bin/env python3
"""
Grep-based checker for Windows cross-platform footguns.
Flags common patterns that break silently on Windows. Run before PRs —
cheap, fast, catches regressions in a codebase that runs on three OSes.
Usage:
# Scan staged changes (default when run from a git checkout)
python scripts/check-windows-footguns.py
# Scan the full tree (full-repo audit)
python scripts/check-windows-footguns.py --all
# Scan a specific file or directory
python scripts/check-windows-footguns.py path/to/file.py path/to/dir/
# Scan only modified files vs. main
python scripts/check-windows-footguns.py --diff main
Exit status:
0 — no Windows footguns found (or all matches suppressed)
1 — at least one unsuppressed match
Suppress an intentional use (e.g. tests or platform-gated code) with:
os.kill(pid, 0) # windows-footgun: ok — only called on POSIX
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE)
# Line-level guard hints. If a line contains any of these tokens, we assume
# the programmer wrote the line in full awareness of the Windows pitfall —
# e.g. `if hasattr(os, 'setsid'): ... os.setsid()`, or the classic
# `getattr(signal, 'SIGKILL', signal.SIGTERM)`, or `shutil.which("wmic")`.
# False negatives are fine here — the inline `# windows-footgun: ok` marker
# is still the authoritative suppression. This is just to reduce the noise
# floor on obviously-guarded lines so the signal-to-noise stays useful.
GUARD_HINTS = (
"hasattr(os,",
"hasattr(signal,",
"getattr(os,",
"getattr(signal,",
"shutil.which(",
"if platform.system() != \"Windows\"",
"if platform.system() != 'Windows'",
"if sys.platform == \"win32\"",
"if sys.platform != \"win32\"",
"if sys.platform == 'win32'",
"if sys.platform != 'win32'",
"IS_WINDOWS",
"is_windows",
)
# Dirs we never scan.
EXCLUDED_DIRS = {
".git",
"node_modules",
"venv",
".venv",
"__pycache__",
"build",
"dist",
".tox",
".mypy_cache",
".pytest_cache",
"site-packages",
"website/build",
"optional-skills", # external skills
}
# File globs we never scan (beyond the dirs above).
EXCLUDED_SUFFIXES = {
".pyc",
".pyo",
".so",
".dll",
".exe",
".png",
".jpg",
".gif",
".ico",
".svg",
".mp4",
".mp3",
".wav",
".pdf",
".zip",
".tar",
".gz",
".whl",
".lock",
".min.js",
".min.css",
}
# Files we never scan (self-referential — this script mentions the
# patterns it detects — and the CONTRIBUTING docs that list them).
EXCLUDED_FILES = {
"scripts/check-windows-footguns.py",
"CONTRIBUTING.md",
}
@dataclass
class Footgun:
"""A Windows cross-platform footgun pattern."""
name: str
pattern: re.Pattern
message: str
fix: str
# If set, matches in files/paths containing any of these substrings are
# silently ignored (e.g. tests that legitimately exercise the footgun
# behind a platform guard). Prefer `# windows-footgun: ok` inline
# suppression over this list; only use path_allowlist for whole files
# that are inherently tests of the footgun itself.
path_allowlist: tuple[str, ...] = ()
# Optional post-match predicate. Takes the re.Match and returns True
# if the match is a REAL footgun (not a false positive). Use this when
# the regex can't fully distinguish (e.g. open() where mode may contain
# "b" for binary, or the line may have `encoding=` elsewhere).
post_filter: "callable | None" = None
FOOTGUNS: list[Footgun] = [
Footgun(
name="open() without encoding= on text mode",
# Match builtins.open() specifically — NOT os.open(), .open()
# method calls (Path.open, tarfile.open, zf.open, webbrowser.open,
# Image.open, wave.open, etc), or `async def open()` method
# definitions. The pattern requires a start-of-identifier boundary
# before `open(` so `os.open`, `.open`, `def open` are all skipped.
# Note: Path.open() is ALSO affected by the encoding default, but
# rather than flagging all `.open(` (huge noise), we require an
# explicit builtins-style open() call. Path.open() is rare in the
# codebase compared to open() and can be audited separately.
pattern=re.compile(
r"""(?:^|[\s\(,;=])(?<![.\w])open\s*\(\s*[^,)]+\s*(?:,\s*['"](?P<mode>[^'"]*)['"])?"""
),
message=(
"open() without an explicit encoding= uses the platform default "
"(UTF-8 on POSIX, cp1252/mbcs on Windows) — files round-tripped "
"between hosts get mojibake. Always pass encoding='utf-8' for "
"text files, or use open(path, 'rb')/'wb' for binary."
),
fix=(
"open(path, 'r', encoding='utf-8') # or 'utf-8-sig' if the "
"file may have a BOM"
),
# Filter: only flag if mode is missing-or-text AND the line doesn't
# already pass encoding=. Skip binary mode (contains "b").
post_filter=lambda m, line: (
"b" not in (m.group("mode") or "")
and "encoding=" not in line
and "encoding =" not in line
# Skip `def open(` and `async def open(` (method definitions)
and not line.lstrip().startswith("def ")
and not line.lstrip().startswith("async def ")
# Skip open(path, **kwargs) patterns — encoding may be in the dict.
# Too expensive to trace; require the author to set encoding in
# the dict and trust them (or they can add a # windows-footgun: ok).
and "**" not in line
),
),
Footgun(
name="os.kill(pid, 0)",
pattern=re.compile(r"\bos\.kill\s*\(\s*[^,]+,\s*0\s*\)"),
message=(
"os.kill(pid, 0) is NOT a no-op on Windows — it sends "
"CTRL_C_EVENT to the target's console process group, "
"hard-killing the target and potentially unrelated siblings. "
"See bpo-14484."
),
fix=(
"Use psutil.pid_exists(pid) (psutil is a core dependency). "
"Or gateway.status._pid_exists(pid) for the hermes wrapper "
"with a stdlib fallback."
),
),
Footgun(
name="bare os.setsid",
pattern=re.compile(r"(?<!hasattr\()\bos\.setsid\b"),
message=(
"os.setsid does not exist on Windows and raises "
"AttributeError. Subprocesses that need detachment on "
"Windows use creationflags instead."
),
fix=(
"if platform.system() != 'Windows':\n"
" kwargs['preexec_fn'] = os.setsid\n"
"else:\n"
" kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP"
),
),
Footgun(
name="bare os.killpg",
pattern=re.compile(r"\bos\.killpg\b"),
message="os.killpg does not exist on Windows.",
fix=(
"Use psutil for cross-platform process-tree kill:\n"
" p = psutil.Process(pid)\n"
" for c in p.children(recursive=True): c.kill()\n"
" p.kill()"
),
),
Footgun(
name="bare os.getuid / os.geteuid / os.getgid",
pattern=re.compile(r"\bos\.(?:getuid|geteuid|getgid|getegid)\b"),
message=(
"os.getuid / os.geteuid / os.getgid do not exist on Windows "
"and raise AttributeError at import time if referenced."
),
fix=(
"Use getpass.getuser() for the username, or gate with "
"hasattr(os, 'getuid')."
),
),
Footgun(
name="bare os.fork",
pattern=re.compile(r"(?<!hasattr\()\bos\.fork\s*\("),
message="os.fork does not exist on Windows.",
fix=(
"Use subprocess.Popen for daemonization, or guard with "
"hasattr(os, 'fork') and a Windows fallback path."
),
),
Footgun(
name="bare signal.SIGKILL",
pattern=re.compile(r"\bsignal\.SIGKILL\b"),
message=(
"signal.SIGKILL does not exist on Windows and raises "
"AttributeError at import time."
),
fix="Use getattr(signal, 'SIGKILL', signal.SIGTERM).",
),
Footgun(
name="bare signal.SIGHUP / SIGUSR1 / SIGUSR2 / SIGALRM / SIGCHLD / SIGPIPE / SIGQUIT",
pattern=re.compile(
r"\bsignal\.(?:SIGHUP|SIGUSR1|SIGUSR2|SIGALRM|SIGCHLD|SIGPIPE|SIGQUIT)\b"
),
message=(
"These POSIX signals don't exist on Windows; referencing "
"them raises AttributeError at import time."
),
fix=(
"Use getattr(signal, 'SIGXXX', None) and check for None "
"before using, or gate the whole block behind a platform check."
),
),
Footgun(
name="subprocess shebang script invocation",
pattern=re.compile(
r"subprocess\.(?:run|Popen|call|check_output|check_call)\s*\(\s*\[\s*['\"]\./"
),
message=(
"Running a script via './scriptname' doesn't work on Windows — "
"shebang lines aren't honored. CreateProcessW can't execute "
"bash/python scripts without an explicit interpreter."
),
fix="Use [sys.executable, 'scriptname.py', ...] explicitly.",
),
Footgun(
name="wmic invocation without shutil.which guard",
# Match wmic appearing as a subprocess argument — NOT the
# shutil.which("wmic") guard pattern itself. Looks for wmic in a
# list or as first arg of subprocess.run/Popen.
pattern=re.compile(
r"""(?:subprocess\.\w+\s*\(\s*\[\s*['"]wmic['"]|['"]wmic\.exe['"])"""
),
message=(
"wmic was removed in Windows 10 21H1 and later. Always "
"gate with shutil.which('wmic') and fall back to "
"PowerShell (Get-CimInstance Win32_Process)."
),
fix=(
"if shutil.which('wmic'):\n"
" ... wmic path ...\n"
"else:\n"
" subprocess.run(['powershell', '-NoProfile', '-Command',\n"
" 'Get-CimInstance Win32_Process | ...'])"
),
),
Footgun(
name="hardcoded ~/Desktop (OneDrive trap)",
pattern=re.compile(
r"""['"](?:~|~/|[A-Z]:[/\\]Users[/\\][^/\\'"]+[/\\])Desktop\b"""
),
message=(
"When OneDrive Backup is enabled on Windows, the real Desktop "
"is at %USERPROFILE%\\OneDrive\\Desktop, not %USERPROFILE%\\"
"Desktop (which exists as an empty husk)."
),
fix=(
"On Windows, resolve via ctypes + SHGetKnownFolderPath, or "
"read the Shell Folders registry key, or run PowerShell "
"[Environment]::GetFolderPath('Desktop')."
),
),
Footgun(
name="asyncio add_signal_handler without try/except",
pattern=re.compile(r"\.add_signal_handler\s*\("),
message=(
"loop.add_signal_handler raises NotImplementedError on "
"Windows — always wrap in try/except or gate with a "
"platform check."
),
fix=(
"try:\n"
" loop.add_signal_handler(sig, handler, sig)\n"
"except NotImplementedError:\n"
" pass # Windows asyncio doesn't support signal handlers"
),
),
]
def should_scan_file(path: Path) -> bool:
"""Return True if this file is in scope for the checker."""
# Skip the excluded dirs
parts = set(path.parts)
if parts & EXCLUDED_DIRS:
return False
# Skip excluded suffixes
for suffix in EXCLUDED_SUFFIXES:
if str(path).endswith(suffix):
return False
# Skip self and docs that intentionally mention the patterns
rel = path.relative_to(REPO_ROOT).as_posix()
if rel in EXCLUDED_FILES:
return False
# Only scan text files (rough heuristic — .py, .md, .sh, .ps1, .yaml, etc.)
if path.suffix in {".py", ".pyw", ".pyi"}:
return True
# Other file types are read but only Python-specific patterns would match;
# that's fine and cheap to skip.
return False
def iter_files(paths: Iterable[Path]) -> Iterable[Path]:
for p in paths:
if p.is_file():
if should_scan_file(p):
yield p
elif p.is_dir():
for root, dirs, files in os.walk(p):
# prune excluded dirs in-place for speed
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
for fname in files:
fpath = Path(root) / fname
if should_scan_file(fpath):
yield fpath
def _strip_code(line: str) -> str:
"""Return just the code portion of a line — strip trailing comments and
skip lines that are entirely inside a string literal or comment.
Heuristic only (we don't parse Python); good enough to avoid flagging
our own `# ``os.kill(pid, 0)`` is NOT a no-op` docstring-style comments.
"""
stripped = line.lstrip()
# Line starts with # — entirely a comment.
if stripped.startswith("#"):
return ""
# Remove trailing "# ..." inline comment. Naive — doesn't handle `#`
# inside strings — but on balance reduces noise far more than it adds.
hash_idx = _find_unquoted_hash(line)
if hash_idx is not None:
return line[:hash_idx]
return line
def _find_unquoted_hash(line: str) -> int | None:
"""Index of the first `#` not inside a single/double/triple-quoted string.
Simple state machine — good enough for the 99% case of "code, then
optional trailing comment."
"""
i = 0
n = len(line)
in_s = False # single-quote string
in_d = False # double-quote string
while i < n:
c = line[i]
if c == "\\" and (in_s or in_d) and i + 1 < n:
i += 2
continue
if not in_d and c == "'":
in_s = not in_s
elif not in_s and c == '"':
in_d = not in_d
elif c == "#" and not in_s and not in_d:
return i
i += 1
return None
def scan_file(path: Path, footguns: list[Footgun]) -> list[tuple[int, str, Footgun]]:
"""Return a list of (line_number, line, footgun) for unsuppressed matches."""
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
matches: list[tuple[int, str, Footgun]] = []
# Track whether we're inside a triple-quoted string (docstring/raw block).
# Simple state machine — handles both ''' and """, toggled by the FIRST
# triple-quote we see; we don't try to handle nested or f-string cases.
in_triple: str | None = None # None, "'''", or '"""'
for i, line in enumerate(text.splitlines(), start=1):
# Update triple-quote state based on this line's occurrences.
code_for_scan = line
if in_triple:
# We're inside a docstring — skip the whole line's scan.
# Check if it closes here.
if in_triple in line:
# Find the closing delimiter; anything after it is real code.
after = line.split(in_triple, 1)[1]
in_triple = None
code_for_scan = after
else:
continue
# Now check for docstring-open in the (possibly after-triple) portion.
# Scan for the first unescaped '''/""" in the current code_for_scan.
stripped = code_for_scan.strip()
for delim in ('"""', "'''"):
if delim in code_for_scan:
# Count occurrences — even count means single-line docstring,
# odd means we've entered a multi-line one.
count = code_for_scan.count(delim)
if count % 2 == 1:
# Odd — we're now inside the triple-quoted block.
# Scan only the part BEFORE the opening delimiter.
before = code_for_scan.split(delim, 1)[0]
code_for_scan = before
in_triple = delim
break
else:
# Even — entire docstring fits on one line. Strip it
# from the scan text to avoid matching on prose.
parts = code_for_scan.split(delim)
# Keep the "outside" parts (every other chunk, starting
# with index 0) as code, drop the "inside" parts.
code_for_scan = "".join(parts[::2])
break
if SUPPRESS_MARKER.search(line):
continue
# Skip if the line has an obvious guard — e.g. hasattr/getattr/
# shutil.which or a platform check. False negatives are acceptable;
# the inline suppression marker is the authoritative override.
if any(hint in line for hint in GUARD_HINTS):
continue
code = _strip_code(code_for_scan)
if not code.strip():
continue
for fg in footguns:
if fg.path_allowlist and any(s in str(path) for s in fg.path_allowlist):
continue
match = fg.pattern.search(code)
if not match:
continue
if fg.post_filter is not None:
try:
if not fg.post_filter(match, line):
continue
except (IndexError, AttributeError):
# Post-filter assumed a named group that isn't there — skip.
continue
matches.append((i, line.rstrip(), fg))
return matches
def get_staged_files() -> list[Path]:
"""Return paths staged in the current git index. Empty on non-git trees."""
try:
out = subprocess.check_output(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,
text=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return []
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
def get_diff_files(ref: str) -> list[Path]:
"""Return paths modified vs. the given git ref."""
try:
out = subprocess.check_output(
["git", "diff", f"{ref}...HEAD", "--name-only", "--diff-filter=ACMR"],
cwd=REPO_ROOT,
stderr=subprocess.DEVNULL,
text=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return []
return [REPO_ROOT / f for f in out.splitlines() if f.strip()]
def parse_args(argv: list[str]) -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Flag Windows cross-platform footguns in Python code."
)
p.add_argument(
"paths",
nargs="*",
type=Path,
help="Specific files/dirs to scan (default: staged changes).",
)
p.add_argument(
"--all",
action="store_true",
help="Scan the full repository (hermes_cli/, gateway/, tools/, cron/, etc.).",
)
p.add_argument(
"--diff",
metavar="REF",
help="Scan files changed vs. the given git ref (e.g. --diff main).",
)
p.add_argument(
"--list",
action="store_true",
help="List all known footgun rules and exit.",
)
return p.parse_args(argv)
def print_rules() -> None:
print("Known Windows footguns checked by this script:\n")
for i, fg in enumerate(FOOTGUNS, start=1):
print(f"{i:2}. {fg.name}")
print(f" {fg.message}")
print(f" Fix: {fg.fix}")
print()
def main(argv: list[str]) -> int:
args = parse_args(argv)
if args.list:
print_rules()
return 0
if args.all:
# Scan main Python packages + scripts
roots = [
REPO_ROOT / "hermes_cli",
REPO_ROOT / "gateway",
REPO_ROOT / "tools",
REPO_ROOT / "cron",
REPO_ROOT / "agent",
REPO_ROOT / "plugins",
REPO_ROOT / "scripts",
REPO_ROOT / "acp_adapter",
REPO_ROOT / "acp_registry",
]
roots = [r for r in roots if r.exists()]
elif args.diff:
roots = get_diff_files(args.diff)
elif args.paths:
roots = [p.resolve() for p in args.paths]
else:
# Default: staged changes
roots = get_staged_files()
if not roots:
print(
"No staged files to scan. Pass --all for a full-repo scan, "
"--diff <ref> for a range diff, or paths explicitly.",
file=sys.stderr,
)
return 0
total_matches = 0
files_scanned = 0
for path in iter_files(roots):
files_scanned += 1
matches = scan_file(path, FOOTGUNS)
for lineno, line, fg in matches:
rel = path.relative_to(REPO_ROOT).as_posix()
print(f"{rel}:{lineno}: [{fg.name}]")
print(f" {line.strip()}")
print(f"{fg.message}")
print(f" Fix: {fg.fix.splitlines()[0]}")
print()
total_matches += 1
if total_matches:
print(
f"\n{total_matches} Windows footgun(s) found across "
f"{files_scanned} file(s) scanned.",
file=sys.stderr,
)
print(
" If an individual match is a false positive or intentionally "
"platform-gated, suppress it with `# windows-footgun: ok` on "
"the same line.\n Run with --list to see all rules.",
file=sys.stderr,
)
return 1
print(
f"✓ No Windows footguns found ({files_scanned} file(s) scanned)."
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -40,7 +40,7 @@ REPO_ROOT = SCRIPT_DIR.parent
IGNORED_PATTERNS = [
re.compile(r"^Claude", re.IGNORECASE),
re.compile(r"^Copilot$", re.IGNORECASE),
re.compile(r"^Cursor\s+Agent$", re.IGNORECASE),
re.compile(r"^Cursor(\s+Agent)?$", re.IGNORECASE),
re.compile(r"^GitHub\s*Actions?$", re.IGNORECASE),
re.compile(r"^dependabot", re.IGNORECASE),
re.compile(r"^renovate", re.IGNORECASE),
@@ -291,7 +291,7 @@ def check_release_file(release_file, all_contributors):
missing: set of handles NOT found in the file
"""
try:
content = Path(release_file).read_text()
content = Path(release_file).read_text(encoding="utf-8")
except FileNotFoundError:
print(f" [error] Release file not found: {release_file}", file=sys.stderr)
return set(), set(all_contributors)

View File

@@ -176,9 +176,12 @@ def check_env_vars():
# Load .env
try:
from dotenv import load_dotenv
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(
hermes_home=ENV_FILE.parent,
project_env=PROJECT_ROOT / ".env",
)
except ImportError:
pass
@@ -239,7 +242,7 @@ def check_config(groq_key, eleven_key):
if config_path.exists():
try:
import yaml
with open(config_path) as f:
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
stt_provider = cfg.get("stt", {}).get("provider", "local")

View File

@@ -191,19 +191,213 @@ function Test-Python {
return $false
}
function Test-Git {
function Install-Git {
<#
.SYNOPSIS
Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe
which Hermes uses to run shell commands.
Priority order (deliberately simple — no winget, no registry, no system
package manager):
1. Existing ``git`` on PATH — use it as-is (the common fast path).
2. Download **PortableGit** from the official git-for-windows GitHub
release (self-extracting 7z.exe) and unpack it to
``%LOCALAPPDATA%\hermes\git`` — never touches system Git, never
requires admin, works even on locked-down machines and machines
with a broken system Git install.
**Why PortableGit, not MinGit:** MinGit is the minimal-automation
distribution and ships ONLY ``git.exe`` — no bash, no POSIX utilities.
Hermes needs ``bash.exe`` to run shell commands. PortableGit is the
full Git for Windows distribution without the installer UI; it ships
``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``,
``ssh``, etc. in ``usr\bin\``.
We deliberately skip winget because it fails badly when the system Git
install is in a half-installed state (partially registered, or uninstall-
blocked). Owning the Hermes copy of Git ourselves is predictable and
recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
and re-running this installer fully recovers.
After install we locate ``bash.exe`` and persist the path in
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
shell without a second PATH refresh.
#>
Write-Info "Checking Git..."
if (Get-Command git -ErrorAction SilentlyContinue) {
$version = git --version
Write-Success "Git found ($version)"
Set-GitBashEnvVar
return $true
}
Write-Err "Git not found"
Write-Info "Please install Git from:"
Write-Info " https://git-scm.com/download/win"
return $false
# Download PortableGit into $HermesHome\git. Always works as long as
# we can reach github.com — no admin, no winget, no reliance on the
# user's possibly-broken system Git install.
Write-Info "Git not found — downloading PortableGit to $HermesHome\git\ ..."
Write-Info "(no admin rights required; isolated from any system Git install)"
try {
$arch = if ([Environment]::Is64BitOperatingSystem) {
# Detect ARM64 vs x64 explicitly; PortableGit ships separate assets.
if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64" -or $env:PROCESSOR_ARCHITEW6432 -eq "ARM64") {
"arm64"
} else {
"64-bit"
}
} else {
# PortableGit does not ship a 32-bit build — fall back to MinGit 32-bit
# with a warning that bash-based features will be unavailable.
"32-bit-mingit"
}
$releaseApi = "https://api.github.com/repos/git-for-windows/git/releases/latest"
$release = Invoke-RestMethod -Uri $releaseApi -UseBasicParsing -Headers @{ "User-Agent" = "hermes-installer" }
if ($arch -eq "32-bit-mingit") {
Write-Warn "32-bit Windows detected — PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine."
$assetPattern = "MinGit-*-32-bit.zip"
$downloadIsZip = $true
} elseif ($arch -eq "arm64") {
$assetPattern = "PortableGit-*-arm64.7z.exe"
$downloadIsZip = $false
} else {
$assetPattern = "PortableGit-*-64-bit.7z.exe"
$downloadIsZip = $false
}
$asset = $release.assets | Where-Object { $_.name -like $assetPattern } | Select-Object -First 1
if (-not $asset) {
throw "Could not find $assetPattern in latest git-for-windows release"
}
$downloadUrl = $asset.browser_download_url
$downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" }
$tmpFile = "$env:TEMP\$($asset.name)"
$gitDir = "$HermesHome\git"
Write-Info "Downloading $($asset.name) ($([math]::Round($asset.size / 1MB, 1)) MB)..."
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing
if (Test-Path $gitDir) {
Write-Info "Removing previous Git install at $gitDir ..."
Remove-Item -Recurse -Force $gitDir
}
New-Item -ItemType Directory -Path $gitDir -Force | Out-Null
if ($downloadIsZip) {
Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force
} else {
# PortableGit is a self-extracting 7z archive. Invoke it with
# `-o<target> -y` (silent) to extract to $gitDir. No 7z install
# required; it's fully self-contained.
Write-Info "Extracting PortableGit to $gitDir ..."
$extractProc = Start-Process -FilePath $tmpFile `
-ArgumentList "-o`"$gitDir`"", "-y" `
-NoNewWindow -Wait -PassThru
if ($extractProc.ExitCode -ne 0) {
throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))"
}
}
Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue
# PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils)
# MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present)
$gitExe = "$gitDir\cmd\git.exe"
if (-not (Test-Path $gitExe)) {
throw "Git extraction did not produce git.exe at $gitExe"
}
# Add to session PATH so the rest of this install run can use git.
$env:Path = "$gitDir\cmd;$env:Path"
# Persist to User PATH so fresh shells see it. PortableGit needs
# cmd\ (for git.exe), bin\ (for bash.exe + core tools), and
# usr\bin\ (for perl, ssh, curl, and other POSIX coreutils).
$newPathEntries = @(
"$gitDir\cmd",
"$gitDir\bin",
"$gitDir\usr\bin"
)
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
$changed = $false
foreach ($entry in $newPathEntries) {
if ($userPathItems -notcontains $entry) {
$userPathItems += $entry
$changed = $true
}
}
if ($changed) {
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
}
$version = & $gitExe --version
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
Set-GitBashEnvVar
return $true
} catch {
Write-Err "Could not install portable Git: $_"
Write-Info ""
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run"
Write-Info "shell commands (same as Claude Code and other coding agents)."
return $false
}
}
function Set-GitBashEnvVar {
<#
.SYNOPSIS
Locate ``bash.exe`` from an already-installed Git and persist the path in
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
PATH propagation completes in a newly-spawned shell.
#>
$candidates = @()
# Our own portable Git install is ALWAYS checked first, so a broken
# system Git doesn't hijack us. If the user had a working system Git
# we'd have returned early from Install-Git's fast path and never called
# this with a system-Git-only installation anyway.
#
# Layouts:
# PortableGit (our default): $HermesHome\git\bin\bash.exe
# MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe
$candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary)
$candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback
# git.exe on PATH can tell us where the install root is
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if ($gitCmd) {
$gitExe = $gitCmd.Source
# Git for Windows (full installer): <root>\cmd\git.exe + <root>\bin\bash.exe
# MinGit: <root>\cmd\git.exe + <root>\usr\bin\bash.exe
$gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
$candidates += "$gitRoot\bin\bash.exe"
$candidates += "$gitRoot\usr\bin\bash.exe"
}
# Standard system install locations as a final fallback. Note:
# ProgramFiles(x86) can't be referenced via ${env:...} string interpolation
# because of the parens — use [Environment]::GetEnvironmentVariable().
$candidates += "${env:ProgramFiles}\Git\bin\bash.exe"
$pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)")
if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" }
$candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe"
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
$env:HERMES_GIT_BASH_PATH = $candidate
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
return
}
}
Write-Warn "Could not locate bash.exe — Hermes may not find Git Bash."
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
function Test-Node {
@@ -411,21 +605,71 @@ function Install-SystemPackages {
function Install-Repository {
Write-Info "Installing to $InstallDir..."
$didUpdate = $false
if (Test-Path $InstallDir) {
# Test-Path "$InstallDir\.git" returns True when .git is a file OR a
# directory OR a symlink OR a submodule-style gitfile — and also when
# it's a broken stub left over from a failed previous install (e.g.
# a partial Remove-Item that couldn't delete a locked index.lock).
# Validate the repo properly by asking git itself. Two checks
# belt-and-braces: rev-parse AND git status. If either fails the
# repo is broken and we fall through to a fresh clone.
$repoValid = $false
if (Test-Path "$InstallDir\.git") {
Push-Location $InstallDir
try {
# Reset $LASTEXITCODE before the probe so we don't pick up
# a stale 0 from an earlier git call in this session.
$global:LASTEXITCODE = 0
$revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1
$revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true")
$global:LASTEXITCODE = 0
$null = & git -c windows.appendAtomically=false status --short 2>&1
$statusOk = ($LASTEXITCODE -eq 0)
if ($revParseOk -and $statusOk) {
$repoValid = $true
}
} catch {}
Pop-Location
}
if ($repoValid) {
Write-Info "Existing installation found, updating..."
Push-Location $InstallDir
git -c windows.appendAtomically=false fetch origin
git -c windows.appendAtomically=false checkout $Branch
git -c windows.appendAtomically=false pull origin $Branch
Pop-Location
try {
git -c windows.appendAtomically=false fetch origin
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false checkout $Branch
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
git -c windows.appendAtomically=false pull origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git pull failed (exit $LASTEXITCODE)" }
} finally {
Pop-Location
}
$didUpdate = $true
} else {
Write-Err "Directory exists but is not a git repository: $InstallDir"
Write-Info "Remove it or choose a different directory with -InstallDir"
throw "Directory exists but is not a git repository: $InstallDir"
# Directory exists but isn't a usable git repo. Wipe it and
# fall through to a fresh clone. A leftover ``.git`` stub from
# a partial uninstall used to lock the installer into the
# "update" branch forever, emitting three ``fatal: not a git
# repository`` errors and failing with "not in a git directory".
Write-Warn "Existing directory at $InstallDir is not a valid git repo — replacing it."
try {
Remove-Item -Recurse -Force $InstallDir -ErrorAction Stop
} catch {
Write-Err "Could not remove $InstallDir : $_"
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
Write-Info "terminals, running hermes processes) and try again."
throw
}
}
} else {
}
if (-not $didUpdate) {
$cloneSuccess = $false
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
@@ -446,7 +690,7 @@ function Install-Repository {
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
$env:GIT_SSH_COMMAND = $null
if (-not $cloneSuccess) {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Info "SSH failed, trying HTTPS..."
@@ -464,18 +708,18 @@ function Install-Repository {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
$zipPath = "$env:TEMP\hermes-agent-$Branch.zip"
$extractPath = "$env:TEMP\hermes-agent-extract"
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
# GitHub ZIPs extract to repo-branch/ subdirectory
$extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
if ($extractedDir) {
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
Move-Item $extractedDir.FullName $InstallDir -Force
Write-Success "Downloaded and extracted"
# Initialize git repo so updates work later
Push-Location $InstallDir
git -c windows.appendAtomically=false init 2>$null
@@ -483,10 +727,10 @@ function Install-Repository {
git remote add origin $RepoUrlHttps 2>$null
Pop-Location
Write-Success "Git repo initialized for future updates"
$cloneSuccess = $true
}
# Cleanup temp files
Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
@@ -499,7 +743,7 @@ function Install-Repository {
throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
}
}
# Set per-repo config (harmless if it fails)
Push-Location $InstallDir
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
@@ -513,7 +757,7 @@ function Install-Repository {
Write-Success "Submodules ready"
}
Pop-Location
Write-Success "Repository ready"
}
@@ -550,26 +794,78 @@ function Install-Dependencies {
$env:VIRTUAL_ENV = "$InstallDir\venv"
}
# Install main package with all extras
try {
& $UvCmd pip install -e ".[all]" 2>&1 | Out-Null
} catch {
& $UvCmd pip install -e "." | Out-Null
# Install main package. Tiered fallback so a single flaky git+https dep
# (atroposlib / tinker in the [rl] extra) doesn't silently drop
# dashboard/MCP/cron/messaging extras. Each tier's stdout/stderr is
# preserved — no Out-Null swallowing — so the user can see what failed.
#
# Tier 1: [all] — everything, including RL git+https deps (best case).
# Tier 2: [core-extras] synthesised locally — all PyPI-only extras we
# ship (web, mcp, cron, cli, voice, messaging, slack, dev, acp,
# pty, homeassistant, sms, tts-premium, honcho, google, mistral,
# bedrock, dingtalk, feishu, modal, daytona, vercel). Drops [rl]
# and [matrix] (linux-only) which are the usual failure culprits.
# Tier 3: [web,mcp,cron,cli,messaging,dev] — the minimum we strongly
# believe a user expects `hermes dashboard` / slash commands /
# cron / messaging platforms to work out of the box.
# Tier 4: bare `.` — last-resort so at least the core CLI launches.
$installTiers = @(
@{ Name = "all (with RL/matrix extras)"; Spec = ".[all]" },
@{ Name = "PyPI-only extras (no git deps)"; Spec = ".[web,mcp,cron,cli,voice,messaging,slack,dev,acp,pty,homeassistant,sms,tts-premium,honcho,google,mistral,bedrock,dingtalk,feishu,modal,daytona,vercel]" },
@{ Name = "dashboard + core platforms"; Spec = ".[web,mcp,cron,cli,messaging,dev]" },
@{ Name = "core only (no extras)"; Spec = "." }
)
$installed = $false
foreach ($tier in $installTiers) {
Write-Info "Trying tier: $($tier.Name) ..."
& $UvCmd pip install -e $tier.Spec
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed ($($tier.Name))"
$script:InstalledTier = $tier.Name
$installed = $true
break
}
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
}
if (-not $installed) {
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
}
# Verify the dashboard deps specifically — they're the most common thing
# users hit and lazy-import errors from `hermes dashboard` are confusing.
# If tier 1 failed (the common case), [web] was still picked up by tiers
# 2-3; only tier 4 leaves you without it.
$pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
if (Test-Path $pythonExe) {
$webOk = $false
try {
& $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $webOk = $true }
} catch { }
if (-not $webOk) {
Write-Warn "fastapi/uvicorn not importable — `hermes dashboard` will not work."
Write-Info "Attempting targeted install of [web] extra as last resort..."
& $UvCmd pip install -e ".[web]"
if ($LASTEXITCODE -eq 0) {
Write-Success "[web] extra installed; `hermes dashboard` should now work."
} else {
Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`""
}
}
}
Write-Success "Main package installed"
# Install optional submodules
Write-Info "Installing tinker-atropos (RL training backend)..."
# tinker-atropos (RL training) is optional and OFF by default. Matches the
# Linux/macOS install.sh behavior. Reasons not to auto-install:
# - tinker-atropos/pyproject.toml pulls atroposlib + tinker from git+https
# (NousResearch/atropos + thinking-machines-lab/tinker) which can fail on
# locked-down networks, flaky DNS, or rate-limited github.com and would
# previously kill the whole install mid-flight on Windows.
# - It's an RL training submodule, not part of the default agent surface.
# Users who don't do RL training never need it.
# Users who do want it can run the one-liner we print below.
if (Test-Path "tinker-atropos\pyproject.toml") {
try {
& $UvCmd pip install -e ".\tinker-atropos" 2>&1 | Out-Null
Write-Success "tinker-atropos installed"
} catch {
Write-Warn "tinker-atropos install failed (RL tools may not work)"
}
} else {
Write-Warn "tinker-atropos not found (run: git submodule update --init)"
Write-Info "tinker-atropos submodule found — skipping install (optional, for RL training)"
Write-Info " To install later: $UvCmd pip install -e `".\tinker-atropos`""
}
Pop-Location
@@ -659,13 +955,21 @@ function Copy-ConfigTemplates {
Write-Info "~/.hermes/config.yaml already exists, keeping it"
}
# Create SOUL.md if it doesn't exist (global persona file)
# Create SOUL.md if it doesn't exist (global persona file).
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
# flags the BOM as an invisible unicode character and refuses to
# load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we
# don't control which PowerShell version the user has. Go direct
# to .NET with an explicit UTF8Encoding($false) — BOM-free on every
# PowerShell version.
$soulPath = "$HermesHome\SOUL.md"
if (-not (Test-Path $soulPath)) {
@"
$soulContent = @"
# Hermes Agent Persona
<!--
<!--
This file defines the agent's personality and tone.
The agent will embody whatever you write here.
Edit this to customize how Hermes communicates with you.
@@ -678,7 +982,9 @@ Examples:
This file is loaded fresh each message -- no restart needed.
Delete the contents (or this file) to use the default personality.
-->
"@ | Set-Content -Path $soulPath -Encoding UTF8
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
}
@@ -708,36 +1014,260 @@ function Install-NodeDeps {
Write-Info "Skipping Node.js dependencies (Node not installed)"
return
}
Push-Location $InstallDir
if (Test-Path "package.json") {
Write-Info "Installing Node.js dependencies (browser tools)..."
try {
npm install --silent 2>&1 | Out-Null
Write-Success "Node.js dependencies installed"
} catch {
Write-Warn "npm install failed (browser tools may not work)"
# Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows
# ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim).
# Get-Command's default ordering picks whichever comes first in PATHEXT,
# and on many systems that's .ps1 — but .ps1 requires scripts to be
# enabled in PowerShell's execution policy, which most Windows users
# don't have (the Restricted / RemoteSigned default blocks unsigned
# .ps1 files). .cmd has no such restriction and works on every box.
#
# Strategy: look next to the npm shim we found and prefer npm.cmd if
# it exists in the same directory. Fall back to whatever Get-Command
# returned if we can't find a .cmd sibling.
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) {
Write-Warn "npm not found on PATH — skipping Node.js dependencies."
Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later."
return
}
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) {
Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)"
$npmExe = $npmCmdSibling
} else {
Write-Warn "Only npm.ps1 available — install may fail if script execution is disabled."
Write-Info " If it fails, either enable PS script execution or install Node via winget."
}
}
# Install TUI dependencies
# Helper: run "npm install" in a given directory and surface the real
# error when it fails. Returns $true on success.
#
# Implementation note: ``Start-Process -FilePath npm.cmd`` fails with
# ``%1 is not a valid Win32 application`` on some PowerShell versions
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
# PE file. The invocation-operator ``& $npmExe`` routes through the
# PowerShell command pipeline which DOES honour .cmd batch shims, so
# it works uniformly for npm.cmd, npx.cmd, and bare .exe files.
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
Push-Location $installDir
try {
# Redirect ALL output streams to the log file via 2>&1 and then
# ``Tee-Object`` / ``Out-File``. Simpler approach: call npm
# with output redirected and inspect $LASTEXITCODE afterwards.
& $npmPath install --silent *> $logPath
$code = $LASTEXITCODE
if ($code -eq 0) {
Write-Success "$label dependencies installed"
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
return $true
}
Write-Warn "$label npm install failed — exit code $code"
if (Test-Path $logPath) {
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
if ($errText) {
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
Write-Info " npm output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
}
Write-Info " Full log: $logPath"
}
}
Write-Info "Run manually later: cd `"$installDir`"; npm install"
return $false
} catch {
Write-Warn "$label npm install could not be launched: $_"
return $false
} finally {
Pop-Location
}
}
# Browser tools
if (Test-Path "$InstallDir\package.json") {
Write-Info "Installing Node.js dependencies (browser tools)..."
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
$browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe
# Install Playwright Chromium (mirrors scripts/install.sh behaviour for
# Linux). Without this, tools/browser_tool.py::check_browser_requirements
# returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the
# browser_* tools are silently filtered out of the agent's tool schema.
# System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by
# agent-browser — it expects a Playwright-managed Chromium.
if ($browserNpmOk) {
Write-Info "Installing browser engine (Playwright Chromium)..."
# npx lives next to npm in the same bin dir. Prefer .cmd to dodge
# the same execution-policy gotcha that affects npm.ps1 (see above).
$npmDir = Split-Path $npmExe -Parent
$npxExe = $null
foreach ($cand in @("npx.cmd", "npx.exe", "npx")) {
$try = Join-Path $npmDir $cand
if (Test-Path $try) { $npxExe = $try; break }
}
if (-not $npxExe) {
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
if ($npxCmd) { $npxExe = $npxCmd.Source }
}
if (-not $npxExe) {
Write-Warn "npx not found — cannot install Playwright Chromium."
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} else {
$pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log"
Push-Location $InstallDir
try {
& $npxExe playwright install chromium *> $pwLog
$pwCode = $LASTEXITCODE
if ($pwCode -eq 0) {
Write-Success "Playwright Chromium installed (browser tools ready)"
Remove-Item -Force $pwLog -ErrorAction SilentlyContinue
} else {
Write-Warn "Playwright Chromium install failed — exit code $pwCode"
Write-Warn "Browser tools will not work until Chromium is installed."
if (Test-Path $pwLog) {
$pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue
if ($pwErr) {
$snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr }
Write-Info " playwright output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
}
Write-Info " Full log: $pwLog"
}
}
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
}
} catch {
Write-Warn "Playwright Chromium install could not be launched: $_"
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} finally {
Pop-Location
}
}
}
}
# TUI
$tuiDir = "$InstallDir\ui-tui"
if (Test-Path "$tuiDir\package.json") {
Write-Info "Installing TUI dependencies..."
Push-Location $tuiDir
try {
npm install --silent 2>&1 | Out-Null
Write-Success "TUI dependencies installed"
} catch {
Write-Warn "TUI npm install failed (hermes --tui may not work)"
}
Pop-Location
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
}
}
function Install-PlatformSdks {
# Ensure messaging-platform SDKs matching tokens the user added to
# ~/.hermes/.env are importable. Two problems this solves:
#
# 1. The tiered `uv pip install` cascade above can fall through to a
# lower tier when the first fails (common when RL git deps choke),
# which silently skips some messaging SDKs from [messaging].
# 2. `uv` creates the venv without pip. If a messaging SDK ends up
# missing, the user can't `pip install python-telegram-bot` to
# recover — pip simply isn't in their venv.
#
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
# for each token set in .env, verify the matching SDK imports. If not,
# run one targeted `pip install` as last-chance recovery. Keeps fresh
# Windows installs from hitting silent "python-telegram-bot not installed"
# at runtime.
if ($NoVenv) {
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
return
}
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
Write-Warn "Skipping platform-SDK verification: $pythonExe not found"
return
}
Pop-Location
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) { return }
$envLines = Get-Content $envPath -ErrorAction SilentlyContinue
# Map: env var set in .env -> (import name, pip spec matching [messaging] extra).
# Specs mirror pyproject.toml to avoid version drift.
$sdkMap = @(
@{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" },
@{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" },
@{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" },
@{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" },
@{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" }
)
# Which tokens are actually set (not placeholder)?
$needed = @()
foreach ($sdk in $sdkMap) {
$match = $envLines | Where-Object {
$_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") `
-and $_ -notmatch "your-token-here" `
-and $_ -notmatch "^\s*#"
}
if ($match) { $needed += $sdk }
}
if ($needed.Count -eq 0) { return }
Write-Host ""
Write-Info "Verifying platform SDKs for tokens found in $envPath ..."
# Verify each SDK's import without triggering side-effect imports.
# Quirk: PowerShell wraps non-zero-exit native stderr as a
# NativeCommandError that prints even with `2>$null` / `*> $null`
# unless we set $ErrorActionPreference to SilentlyContinue for the
# span. Save + restore rather than nuking globally.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
$missing = @()
foreach ($sdk in $needed) {
& $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
$missing += $sdk
Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))"
} else {
Write-Success " $($sdk.Import) OK"
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
if ($missing.Count -eq 0) { return }
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
# without pip; ensurepip is the stdlib-blessed way to add it.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
& $pythonExe -m pip --version 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..."
& $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warn "ensurepip failed — can't auto-install missing SDKs."
Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
return
}
}
foreach ($sdk in $missing) {
Write-Info " Installing $($sdk.Spec) ..."
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -eq 0) {
Write-Success " Installed $($sdk.Import)"
} else {
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
}
function Invoke-SetupWizard {
@@ -886,13 +1416,35 @@ function Write-Completion {
function Main {
Write-Banner
# Windows refuses to delete a directory any shell is currently cd'd
# inside — and silently leaves orphan files behind, which then wedge
# "is this a valid git repo" probes on re-install. If the current
# working dir is under $InstallDir, step out to the user's home
# BEFORE doing anything else. Harmless when the user ran the
# installer from somewhere else.
try {
$currentResolved = (Get-Location).ProviderPath
$installResolved = $null
if (Test-Path $InstallDir) {
$installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath
}
if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) {
Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..."
Set-Location $env:USERPROFILE
}
} catch {}
if (-not (Install-Uv)) { throw "uv installation failed — cannot continue" }
if (-not (Test-Python)) { throw "Python $PythonVersion not available — cannot continue" }
if (-not (Test-Git)) { throw "Git not found — install from https://git-scm.com/download/win" }
Test-Node # Auto-installs if missing
if (-not (Install-Git)) { throw "Git not available and auto-install failed — install from https://git-scm.com/download/win then re-run" }
# Test-Node always returns $true (sets $script:HasNode on success, emits a
# warning on failure and continues so non-browser installs still work).
# Cast to [void] so the bare return value doesn't print "True" to the
# console between the "Node found" line and the next installer step.
[void](Test-Node)
Install-SystemPackages # ripgrep + ffmpeg in one step
Install-Repository
Install-Venv
Install-Dependencies
@@ -900,8 +1452,9 @@ function Main {
Set-PathVariable
Copy-ConfigTemplates
Invoke-SetupWizard
Install-PlatformSdks
Start-GatewayIfConfigured
Write-Completion
}

View File

@@ -15,6 +15,23 @@
set -e
# Guard against environment leakage when the installer is launched from another
# Python-driven tool session (e.g. Hermes terminal tool). A pre-set PYTHONPATH
# can force pip/entrypoints to import a different checkout than the one being
# installed, which makes fresh installs appear broken or stale.
if [ -n "${PYTHONPATH:-}" ]; then
echo "⚠ Ignoring inherited PYTHONPATH during install to avoid module shadowing"
unset PYTHONPATH
fi
if [ -n "${PYTHONHOME:-}" ]; then
echo "⚠ Ignoring inherited PYTHONHOME during install"
unset PYTHONHOME
fi
# Prevent uv from discovering config files (uv.toml, pyproject.toml) from the
# wrong user's home directory when running under sudo -u <user>. See #21269.
export UV_NO_CONFIG=1
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -602,6 +619,41 @@ install_node() {
HAS_NODE=true
}
check_network_prerequisites() {
log_info "Checking internet connectivity for package install and web tools..."
local url
local failed=false
local checks=("https://pypi.org/simple/" "https://duckduckgo.com/")
if ! command -v curl >/dev/null 2>&1; then
log_warn "curl not found; skipping connectivity probes"
return 0
fi
for url in "${checks[@]}"; do
if ! curl -fsSI --max-time 8 "$url" >/dev/null 2>&1; then
failed=true
log_warn "Could not reach $url"
fi
done
if [ "$failed" = false ]; then
log_success "Internet connectivity looks good"
return 0
fi
if [ "$DISTRO" = "termux" ]; then
log_warn "Termux network prerequisites may be incomplete."
log_info "Try: pkg install -y ca-certificates curl && pkg update"
log_info "If mirrors are stale: termux-change-repo"
log_info "Then test: curl -I https://pypi.org/simple/ && curl -I https://duckduckgo.com/"
else
log_warn "Network checks failed. Hermes install may complete, but web search and dependency downloads can fail."
log_info "Verify internet/DNS and retry if pip install fails."
fi
}
install_system_packages() {
# Detect what's missing
HAS_RIPGREP=false
@@ -629,7 +681,7 @@ install_system_packages() {
# Termux always needs the Android build toolchain for the tested pip path,
# even when ripgrep/ffmpeg are already present.
if [ "$DISTRO" = "termux" ]; then
local termux_pkgs=(clang rust make pkg-config libffi openssl)
local termux_pkgs=(clang rust make pkg-config libffi openssl ca-certificates curl)
if [ "$need_ripgrep" = true ]; then
termux_pkgs+=("ripgrep")
fi
@@ -932,17 +984,37 @@ install_deps() {
fi
"$PIP_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
log_warn "Termux feature install (.[termux]) failed, trying base install..."
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
log_error "Package installation failed on Termux."
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl"
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
exit 1
# On Android, psutil's setup.py rejects sys.platform == 'android' before
# it ever invokes the C build, so the next pip install would fail at
# "platform android is not supported". Prebuild psutil from the official
# sdist with a one-line marker patch (Linux source path is fine on
# Android). Stopgap until psutil#2762 ships upstream.
if "$PIP_PYTHON" -c 'import sys; raise SystemExit(0 if sys.platform == "android" else 1)' 2>/dev/null; then
log_info "Android Python detected: prebuilding psutil compatibility shim..."
if ! "$PIP_PYTHON" "$INSTALL_DIR/scripts/install_psutil_android.py" --pip "$PIP_PYTHON -m pip"; then
log_warn "psutil Android prebuild failed — package install will likely fail next."
log_info "Workaround: manually rerun 'python scripts/install_psutil_android.py' once your toolchain is set up."
fi
fi
# Try the broad Termux profile first (best-effort "install all" for Android),
# then fall back to the conservative Termux baseline, then base package.
if ! "$PIP_PYTHON" -m pip install -e '.[termux-all]' -c constraints-termux.txt; then
log_warn "Termux broad profile (.[termux-all]) failed, trying baseline Termux profile..."
if ! "$PIP_PYTHON" -m pip install -e '.[termux]' -c constraints-termux.txt; then
log_warn "Termux baseline profile (.[termux]) failed, trying base install..."
if ! "$PIP_PYTHON" -m pip install -e '.' -c constraints-termux.txt; then
log_error "Package installation failed on Termux."
log_info "Ensure these packages are installed: pkg install clang rust make pkg-config libffi openssl ca-certificates curl"
log_info "Then re-run: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
exit 1
fi
fi
fi
log_success "Main package installed"
log_info "Termux note: matrix e2ee and local faster-whisper extras are excluded from .[termux-all] due to upstream Android wheel/toolchain blockers."
log_info "Termux note: browser/WhatsApp tooling is not installed by default; see the Termux guide for optional follow-up steps."
if [ -d "tinker-atropos" ] && [ -f "tinker-atropos/pyproject.toml" ]; then
@@ -1034,7 +1106,7 @@ setup_path() {
log_warn "hermes entry point not found at $HERMES_BIN"
log_info "This usually means the pip install didn't complete successfully."
if [ "$DISTRO" = "termux" ]; then
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux]' -c constraints-termux.txt"
log_info "Try: cd $INSTALL_DIR && python -m pip install -e '.[termux-all]' -c constraints-termux.txt"
else
log_info "Try: cd $INSTALL_DIR && uv pip install -e '.[all]'"
fi
@@ -1047,9 +1119,17 @@ setup_path() {
command_link_display_dir="$(get_command_link_display_dir)"
# Create a user-facing shim for the hermes command.
# We intentionally clear PYTHONPATH/PYTHONHOME here so inherited env vars
# can't make this launcher import modules from another checkout.
mkdir -p "$command_link_dir"
ln -sf "$HERMES_BIN" "$command_link_dir/hermes"
log_success "Symlinked hermes → $command_link_display_dir/hermes"
cat > "$command_link_dir/hermes" <<EOF
#!/usr/bin/env bash
unset PYTHONPATH
unset PYTHONHOME
exec "$HERMES_BIN" "\$@"
EOF
chmod +x "$command_link_dir/hermes"
log_success "Installed hermes launcher → $command_link_display_dir/hermes"
if [ "$DISTRO" = "termux" ]; then
export PATH="$command_link_dir:$PATH"
@@ -1549,6 +1629,7 @@ main() {
check_python
check_git
check_node
check_network_prerequisites
install_system_packages
clone_repo

117
scripts/install_psutil_android.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Install psutil on Termux/Android by patching upstream platform detection.
psutil's setup currently gates Linux sources behind
``sys.platform.startswith('linux')``. On Termux, Python reports
``sys.platform == 'android'``, so ``pip install psutil`` aborts with
"platform android is not supported" — even though psutil compiles fine
when the Linux source path is reused.
This script downloads the official psutil sdist, applies a one-line
patch (``LINUX = sys.platform.startswith(("linux", "android"))``), and
installs the patched tree with ``pip install --no-build-isolation``.
Usage:
python scripts/install_psutil_android.py [--pip "/path/to/pip"] [--uv]
When neither flag is given, the script auto-detects ``uv`` on PATH and
falls back to ``<sys.executable> -m pip``.
This is a stopgap. Remove once psutil upstream merges
https://github.com/giampaolo/psutil/pull/2762 and ships a release.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request
from pathlib import Path
# Pin a version we know patches cleanly. Update when a newer psutil
# changes the marker line shape and we need to follow upstream.
PSUTIL_URL = (
"https://files.pythonhosted.org/packages/aa/c6/"
"d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/"
"psutil-7.2.2.tar.gz"
)
MARKER = 'LINUX = sys.platform.startswith("linux")'
REPLACEMENT = 'LINUX = sys.platform.startswith(("linux", "android"))'
def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]:
if pip_arg:
return pip_arg.split()
if prefer_uv:
uv = shutil.which("uv")
if not uv:
sys.exit("--uv requested but no uv on PATH")
return [uv, "pip"]
auto_uv = shutil.which("uv")
if auto_uv:
return [auto_uv, "pip"]
return [sys.executable, "-m", "pip"]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pip",
help="Explicit installer command (e.g. '/usr/bin/uv pip' or 'python -m pip')",
)
parser.add_argument(
"--uv",
action="store_true",
help="Force using uv (errors out if uv is not on PATH)",
)
args = parser.parse_args()
install_cmd_prefix = _resolve_install_cmd(args.pip, args.uv)
print(
"→ Termux/Android: prebuilding psutil with Linux source path "
"compatibility shim (see psutil#2762)..."
)
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
archive = tmp_path / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive)
with tarfile.open(archive) as tar:
tar.extractall(tmp_path)
try:
src_root = next(
p for p in tmp_path.iterdir()
if p.is_dir() and p.name.startswith("psutil-")
)
except StopIteration:
sys.exit("psutil sdist did not contain a psutil-* directory")
common_py = src_root / "psutil" / "_common.py"
content = common_py.read_text(encoding="utf-8")
if MARKER not in content:
sys.exit(
"psutil Android compatibility patch marker not found — "
"upstream may have changed the LINUX detection line. "
"Update MARKER/REPLACEMENT in this script."
)
common_py.write_text(content.replace(MARKER, REPLACEMENT), encoding="utf-8")
cmd = install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)]
print(f" $ {' '.join(cmd)}")
result = subprocess.run(cmd)
if result.returncode != 0:
return result.returncode
print("✓ psutil installed via Android compatibility shim")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Diagnose how prompt_toolkit identifies keystrokes in the current terminal.
Useful when adding a keybinding to Hermes (or any prompt_toolkit app) and you
need to know what the terminal actually delivers — particularly on Windows,
where terminals can collapse, intercept, or silently remap key combinations.
Usage:
# POSIX
python scripts/keystroke_diagnostic.py
# Windows (PowerShell / git-bash / cmd)
python scripts\\keystroke_diagnostic.py
Press the key combinations you care about. Each keystroke prints the
prompt_toolkit `Keys.*` identifier and the raw escape bytes the terminal
sent. The last 20 keystrokes stay on screen. Ctrl+Q or Ctrl+C to quit.
Common questions this answers:
- Does my terminal distinguish Ctrl+Enter from plain Enter?
(On Windows Terminal: yes, Ctrl+Enter → c-j, Enter → c-m.)
- Does Alt+Enter reach the app, or does the terminal eat it?
(Windows Terminal eats it for fullscreen; mintty may too.)
- Does Shift+Enter register as a separate key?
(Almost never — most terminals collapse it to Enter.)
- What byte sequence does Home/End/PageUp/etc. produce?
Example output for Ctrl+Enter on Windows Terminal + PowerShell:
key=<Keys.ControlJ: 'c-j'> data='\\n'
Then in Hermes, bind the newline behaviour to that key:
@kb.add('c-j')
def handle_ctrl_enter(event):
event.current_buffer.insert_text('\\n')
"""
from prompt_toolkit import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import Window
from prompt_toolkit.layout.controls import FormattedTextControl
_HISTORY: list[str] = []
def _header() -> list[str]:
return [
"Keystroke diagnostic — press keys to see how prompt_toolkit sees them.",
"Try: Enter, Ctrl+Enter, Shift+Enter, Alt+Enter, Ctrl+J, Ctrl+M, arrows, Home/End.",
"Ctrl+Q or Ctrl+C to quit. Last 20 keystrokes shown.",
"",
]
def _render_text() -> str:
return "\n".join(_header() + _HISTORY[-20:])
def main() -> None:
kb = KeyBindings()
@kb.add("<any>")
def _on_any(event): # noqa: ANN001 — prompt_toolkit event type
parts = []
for kp in event.key_sequence:
parts.append(f"key={kp.key!r} data={kp.data!r}")
_HISTORY.append(" | ".join(parts))
event.app.invalidate()
@kb.add("c-q")
@kb.add("c-c")
def _quit(event): # noqa: ANN001
event.app.exit()
control = FormattedTextControl(text=_render_text)
layout = Layout(Window(content=control))
Application(layout=layout, key_bindings=kb, full_screen=False).run()
if __name__ == "__main__":
main()

207
scripts/lint_diff.py Executable file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Diff ruff + ty diagnostic reports between two git refs.
Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
comments. Compares issues by a stable key (file, rule, line) so line-only
shifts from unrelated edits are treated as the same issue.
Usage:
lint_diff.py \\
--base-ruff base/ruff.json --head-ruff head/ruff.json \\
--base-ty base/ty.json --head-ty head/ty.json \\
[--base-ref origin/main] [--head-ref HEAD]
Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
config yet, or a tool crashed).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter
from pathlib import Path
def _load_json(path: Path | None) -> list[dict]:
if path is None or not path.exists() or path.stat().st_size == 0:
return []
try:
data = json.loads(path.read_text())
except json.JSONDecodeError as exc:
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
return []
if not isinstance(data, list):
return []
return data
def _normalize_ruff(entries: list[dict]) -> list[dict]:
"""Ruff JSON: {code, filename, location.row, message}."""
out: list[dict] = []
for e in entries:
code = e.get("code") or "unknown"
# ruff emits absolute paths; relativize to repo root if possible
filename = e.get("filename", "")
try:
filename = os.path.relpath(filename)
except ValueError:
pass
line = (e.get("location") or {}).get("row", 0)
out.append(
{
"tool": "ruff",
"rule": code,
"path": filename,
"line": line,
"message": e.get("message", ""),
}
)
return out
def _normalize_ty(entries: list[dict]) -> list[dict]:
"""ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
out: list[dict] = []
for e in entries:
loc = e.get("location") or {}
begin = (loc.get("positions") or {}).get("begin") or {}
out.append(
{
"tool": "ty",
"rule": e.get("check_name", "unknown"),
"path": loc.get("path", ""),
"line": begin.get("line", 0),
"message": e.get("description", ""),
}
)
return out
def _key(d: dict) -> tuple[str, str, str]:
"""Stable diagnostic identity across commits: (path, rule, message)."""
# Intentionally omit line so unrelated edits above an issue don't flag it
# as "new". Same file + same rule + same message = same issue.
return (d["path"], d["rule"], d["message"])
def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
base_map = {_key(d): d for d in base}
head_map = {_key(d): d for d in head}
base_keys = set(base_map)
head_keys = set(head_map)
new_keys = head_keys - base_keys
fixed_keys = base_keys - head_keys
unchanged_keys = base_keys & head_keys
# Return head entries for new (current line numbers), base entries for fixed
return (
[head_map[k] for k in new_keys],
[base_map[k] for k in fixed_keys],
[head_map[k] for k in unchanged_keys],
)
def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
return Counter(e["rule"] for e in entries).most_common()
def _section(title: str, entries: list[dict], limit: int = 25) -> str:
if not entries:
return f"**{title}:** none\n"
lines = [f"**{title} ({len(entries)}):**\n"]
# Group by rule for readability
counts = _rule_counts(entries)
lines.append("| Rule | Count |")
lines.append("| --- | ---: |")
for rule, count in counts[:15]:
lines.append(f"| `{rule}` | {count} |")
if len(counts) > 15:
lines.append(f"| _+{len(counts) - 15} more rules_ | |")
lines.append("")
lines.append("<details><summary>First entries</summary>\n")
lines.append("```")
for e in entries[:limit]:
lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
if len(entries) > limit:
lines.append(f"... and {len(entries) - limit} more")
lines.append("```")
lines.append("</details>\n")
return "\n".join(lines)
def _tool_report(
tool_name: str,
base: list[dict],
head: list[dict],
base_available: bool,
) -> str:
new, fixed, unchanged = _diff(base, head)
delta = len(head) - len(base)
delta_str = f"+{delta}" if delta > 0 else str(delta)
emoji = "🆕" if delta > 0 else ("" if delta < 0 else "")
lines = [f"## {tool_name}\n"]
if not base_available:
lines.append(
"_Base report unavailable (likely main has no config for this tool yet); "
"treating all head diagnostics as new._\n"
)
lines.append(
f"**Total:** {len(head)} on HEAD, {len(base)} on base "
f"({emoji} {delta_str})\n"
)
lines.append(_section("🆕 New issues", new))
lines.append(_section("✅ Fixed issues", fixed))
lines.append(
f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
)
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base-ruff", type=Path, required=True)
ap.add_argument("--head-ruff", type=Path, required=True)
ap.add_argument("--base-ty", type=Path, required=True)
ap.add_argument("--head-ty", type=Path, required=True)
ap.add_argument("--base-ref", default="base")
ap.add_argument("--head-ref", default="HEAD")
ap.add_argument(
"--output", type=Path, help="Write summary to this file instead of stdout"
)
args = ap.parse_args()
base_ruff_raw = _load_json(args.base_ruff)
head_ruff_raw = _load_json(args.head_ruff)
base_ty_raw = _load_json(args.base_ty)
head_ty_raw = _load_json(args.head_ty)
base_ruff = _normalize_ruff(base_ruff_raw)
head_ruff = _normalize_ruff(head_ruff_raw)
base_ty = _normalize_ty(base_ty_raw)
head_ty = _normalize_ty(head_ty_raw)
base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
buf: list[str] = []
buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
buf.append(
"_Diagnostics are surfaced as warnings — this check never fails the build._\n"
)
summary = "\n".join(buf)
if args.output:
args.output.write_text(summary)
else:
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -35,13 +35,21 @@ import time
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_PROJECT_ROOT))
try:
from hermes_constants import get_hermes_home
except ImportError:
def get_hermes_home() -> Path: # type: ignore[misc]
val = (os.environ.get("HERMES_HOME") or "").strip()
return Path(val) if val else Path.home() / ".hermes"
DEFAULT_TUI_DIR = Path(
os.environ.get("HERMES_TUI_DIR")
or str(Path(__file__).resolve().parent.parent / "ui-tui")
)
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(Path.home() / ".hermes" / "perf.log")))
DEFAULT_STATE_DB = Path.home() / ".hermes" / "state.db"
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log")))
DEFAULT_STATE_DB = get_hermes_home() / "state.db"
# Keystroke escape sequences. Matches what xterm/VT220 send when the
# terminal has bracketed-paste disabled and the key-repeat handler fires.
@@ -106,7 +114,7 @@ def summarize(log: Path, since_ts_ms: int) -> dict[str, Any]:
frame_events: list[dict[str, Any]] = []
if not log.exists():
return {"error": f"no log at {log}", "react": [], "frame": []}
for line in log.read_text().splitlines():
for line in log.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
@@ -338,7 +346,7 @@ def key_metrics(data: dict[str, Any]) -> dict[str, float]:
metrics["backpressure_frames"] = bp
if react:
for pid in set(e["id"] for e in react):
for pid in {e["id"] for e in react}:
ms = [e["actualMs"] for e in react if e["id"] == pid]
metrics[f"react_{pid}_p99"] = pct(ms, 0.99)
metrics[f"react_{pid}_max"] = max(ms)
@@ -355,7 +363,7 @@ def format_diff(before: dict[str, float], after: dict[str, float]) -> str:
b = before.get(k, 0.0)
a = after.get(k, 0.0)
d = a - b
pct_change = ((a / b) - 1) * 100 if b not in (0, 0.0) else float("inf") if a else 0
pct_change = ((a / b) - 1) * 100 if b not in {0, 0.0} else float("inf") if a else 0
# Flag improvements vs regressions. For _p99 / _max / _total / gaps_over /
# patches / writeBytes / backpressure, LOWER is better. For fps / gaps_under,
@@ -452,7 +460,7 @@ def run_once(args: argparse.Namespace) -> dict[str, Any]:
break
time.sleep(0.1)
else:
os.kill(pid, signal.SIGKILL)
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — POSIX-only script (imports pty at top)
os.waitpid(pid, 0)
except (ProcessLookupError, ChildProcessError):
pass
@@ -500,7 +508,7 @@ def main() -> int:
if args.save:
path = Path(f"/tmp/perf-{args.save}.json")
path.write_text(json.dumps(metrics, indent=2))
path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(f"\n• saved: {path}")
if args.compare:

View File

@@ -41,14 +41,141 @@ PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
AUTHOR_MAP = {
# teknium (multiple emails)
"teknium1@gmail.com": "teknium1",
"0x.badfriend@gmail.com": "discodirector",
"altriatree@gmail.com": "TruaShamu",
"m@mobrienv.dev": "mikeyobrien",
"qiyin.zuo@pcitc.com": "qiyin-code",
"oleksii.lisikh@gmail.com": "olisikh",
"leone.parise@gmail.com": "leoneparise",
"buraysandro9@gmail.com": "ygd58",
"teknium@nousresearch.com": "teknium1",
"piyushvp1@gmail.com": "thelumiereguy",
"421774554@qq.com": "wuli666",
"harish.kukreja@gmail.com": "counterposition",
"1046611633@qq.com": "zhengyn0001",
"cleo@edaphic.xyz": "curiouscleo",
"hirokazu.ogawa@kwansei.ac.jp": "hrkzogw",
"datapod.k@gmail.com": "dandacompany",
"treydong.zh@gmail.com": "TreyDong",
"127238744+teknium1@users.noreply.github.com": "teknium1",
"hugosequier@gmail.com": "Hugo-SEQUIER",
"128259593+Gutslabs@users.noreply.github.com": "Gutslabs",
"50326054+nocturnum91@users.noreply.github.com": "nocturnum91",
"223003280+Abd0r@users.noreply.github.com": "Abd0r",
"HuangYuChuh@users.noreply.github.com": "HuangYuChuh",
"aaronwong1989@gmail.com": "hrygo",
"26729613+hrygo@users.noreply.github.com": "hrygo",
"erenkar950@gmail.com": "eren-karakus0",
"aubrey@freeman-wisco.com": "Freeman-Consulting",
"don.rhm@gmail.com": "rahimsais",
"40222899+rahimsais@users.noreply.github.com": "rahimsais",
"alfred@Alfreds-Mac-mini.local": "NivOO5",
"231191380+NivOO5@users.noreply.github.com": "NivOO5",
"jameshuang@gmail.com": "kjames2001",
"62420081+kjames2001@users.noreply.github.com": "kjames2001",
"132184373+wilsen0@users.noreply.github.com": "wilsen0",
"ra2157218@gmail.com": "Abd0r",
"abdielv@proton.me": "AJV20",
"mason@growagainorchids.com": "masonjames",
"ytchen0719@gmail.com": "liquidchen",
"am@studio1.tailb672fe.ts.net": "subtract0",
"mike@grossmann.at": "ReqX",
"axmaiqiu@gmail.com": "qWaitCrypto",
"44045911+kidonng@users.noreply.github.com": "kidonng",
"daniellsmarta@gmail.com": "DanielLSM",
"264291321+v1b3coder@users.noreply.github.com": "v1b3coder",
"silverchris@foxmail.com": "ming1523",
"maksesipov@gmail.com": "Qwinty",
"denisamania@gmail.com": "CalmProton",
"308068+mbac@users.noreply.github.com": "mbac",
"ninso112@proton.me": "Ninso112",
"wesleysimplicio@live.com": "wesleysimplicio",
"matthew.dean.cater@gmail.com": "SiliconID",
"xieniu@proton.me": "xieNniu",
"rw8143a@american.edu": "wali-reheman",
"egitimviscara@gmail.com": "uzunkuyruk",
"zhekinmaksim@gmail.com": "Zhekinmaksim",
"obafemiferanmi1999@gmail.com": "KvnGz",
"159539633+MottledShadow@users.noreply.github.com": "MottledShadow",
"aludwin+gh@gmail.com": "adamludwin",
"ngusev@astralinux.ru": "NikolayGusev-astra",
"liuguangyong201@hellobike.com": "liuguangyong93",
"2093036+exiao@users.noreply.github.com": "exiao",
"20nik.nosov21@gmail.com": "nik1t7n",
"thunderggnn@gmail.com": "ggnnggez",
"haozhe4547@gmail.com": "ehz0ah",
"kevyan1998@gmail.com": "kyan12",
"rylen.anil@gmail.com": "rylena",
"godnanijatin@gmail.com": "jatingodnani",
"252811164+adybag14-cyber@users.noreply.github.com": "adybag14-cyber",
"14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
"112875006+donramon77@users.noreply.github.com": "donramon77",
"657290301@qq.com": "IMHaoyan",
"revar@users.noreply.github.com": "revaraver",
"dengtaoyuan@dengtaoyuandeMac-mini.local": "dengtaoyuan450-a11y",
"ysfalweshcan@gmail.com": "Junass1",
"bartokmagic@proton.me": "Bartok9",
"androidhtml@yandex.com": "hllqkb",
"25840394+Bongulielmi@users.noreply.github.com": "Bongulielmi",
"jonathan.troyer@overmatch.com": "JTroyerOvermatch",
"harryykyle1@gmail.com": "hharry11",
"wysie@users.noreply.github.com": "wysie",
"jkausel@gmail.com": "jkausel-ai",
"e.silacandmr@gmail.com": "Es1la",
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"sandrohub013@gmail.com": "SandroHub013",
"maciekczech@users.noreply.github.com": "maciekczech",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"zjtan1@gmail.com": "zeejaytan",
"asslaenn5@gmail.com": "Aslaaen",
"trae.anderson17@icloud.com": "Tkander1715",
"beardthelion@users.noreply.github.com": "beardthelion",
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
"leon@agentlinker.ai": "agentlinker",
"santoshhumagain1887@gmail.com": "npmisantosh",
"novax635@gmail.com": "novax635",
"krionex1@gmail.com": "Krionex",
"rxdxxxx@users.noreply.github.com": "rxdxxxx",
"ma.haohao2@xydigit.com": "MaHaoHao-ch",
"29756950+revaraver@users.noreply.github.com": "revaraver",
"nexus@eptic.me": "TheEpTic",
"74554762+wmagev@users.noreply.github.com": "wmagev",
"ashermorse@icloud.com": "ashermorse",
"happy5318@users.noreply.github.com": "happy5318",
"anatoliygranichenko@gmail.com": "wabrent",
"cash.williams@acquia.com": "CashWilliams",
"chengoak@users.noreply.github.com": "chengoak",
"mrhanoi@outlook.com": "qxxaa",
"guillaume.meyer@outlook.com": "guillaumemeyer",
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
"lazycat.manatee@gmail.com": "manateelazycat",
"bzarnitz13@gmail.com": "Beandon13",
"tony@tonysimons.dev": "asimons81",
"jetha@google.com": "jethac",
"jani@0xhoneyjar.xyz": "deep-name",
# LINE messaging plugin (synthesis PR)
"32443648+leepoweii@users.noreply.github.com": "leepoweii",
"openclaw@liyangchen.me": "liyoungc",
"charles@perng.com": "perng",
"soichiro0111.dev@gmail.com": "soichiyo",
"0xde@pieverse.io": "David-0x221Eight",
"77736378+David-0x221Eight@users.noreply.github.com": "David-0x221Eight",
"74749461+yuga-hashimoto@users.noreply.github.com": "yuga-hashimoto",
"xiangyong@zspace.cn": "CES4751",
"harish.kukreja@gmail.com": "counterposition",
"35294173+Fearvox@users.noreply.github.com": "Fearvox",
"hypnus.yuan@gmail.com": "Hypnus-Yuan",
"15558128926@qq.com": "xsfX20",
"binhnt.ht.92@gmail.com": "binhnt92",
"johnny@Jons-MBA-M4.local": "acesjohnny",
"1581133593@qq.com": "liu-collab",
"haidaoe@proton.me": "haidao1919",
"50561768+zhanggttry@users.noreply.github.com": "zhanggttry",
"formulahendry@gmail.com": "formulahendry",
"93757150+bogerman1@users.noreply.github.com": "bogerman1",
"132852777+rob-maron@users.noreply.github.com": "rob-maron",
# Matrix parity salvage batch (April 2026)
"sr@samirusani": "samrusani",
"angelclaw@AngelMacBook.local": "angel12",
@@ -57,12 +184,18 @@ AUTHOR_MAP = {
"luwinyang@deepseek.com": "lsdsjy",
"season.saw@gmail.com": "season179",
"heathley@Heathley-MacBook-Air.local": "heathley",
"maliyldzhn@gmail.com": "heathley",
"vlad19@gmail.com": "dandaka",
"adamrummer@gmail.com": "cyclingwithelephants",
# Temporary tool-progress cleanup salvage (May 2026)
"Mrcharlesiv@gmail.com": "mrcharlesiv",
"nbot@liizfq.top": "liizfq",
"274096618+hermes-agent-dhabibi@users.noreply.github.com": "dhabibi",
"dejie.guo@gmail.com": "JayGwod",
"133716830+0xKingBack@users.noreply.github.com": "0xKingBack",
"daixin1204@gmail.com": "SimbaKingjoe",
"maxence@groine.fr": "MaxyMoos",
"61830395+leprincep35700@users.noreply.github.com": "leprincep35700",
# OpenViking viking_read salvage (April 2026)
"hitesh@gmail.com": "htsh",
"pty819@outlook.com": "pty819",
@@ -71,17 +204,33 @@ AUTHOR_MAP = {
# Curator fixes (Apr 30 2026)
"yuxiangl490@gmail.com": "y0shua1ee",
"manmit0x@gmail.com": "0xDevNinja",
"stevekelly622@gmail.com": "steezkelly",
"momowind@gmail.com": "momowind",
"clockwork-codex@users.noreply.github.com": "misery-hl",
"207811921+misery-hl@users.noreply.github.com": "misery-hl",
"20nik.nosov21@gmail.com": "nik1t7n",
"90299797+nik1t7n@users.noreply.github.com": "nik1t7n",
"suncokret@protonmail.com": "suncokret12",
"mio.imoto.ai@gmail.com": "mioimotoai-lgtm",
"aamirjawaid@microsoft.com": "heyitsaamir",
"johnnncenaaa77@gmail.com": "johnncenae",
"thomasjhon6666@gmail.com": "ThomassJonax",
"focusflow.app.help@gmail.com": "yes999zc",
"rob@atlas.lan": "rmoen",
# Slack ephemeral slash-ack salvage (May 2026)
"probepark@users.noreply.github.com": "probepark",
# Slack batch salvage (May 2026)
"280484231+prive-fe-bot@users.noreply.github.com": "priveperfumes",
"amr@ghanem.sa": "amroessam",
"paperlantern.agent@gmail.com": "Hinotoi-agent",
"valda@underscore.jp": "valda",
"162235745+0z1-ghb@users.noreply.github.com": "0z1-ghb",
"yes999zc@163.com": "yes999zc",
"343873859@qq.com": "DrStrangerUJN",
"252818347@qq.com": "hejuntt1014",
"uzmpsk.dilekakbas@gmail.com": "dlkakbs",
"beliefanx@gmail.com": "BeliefanX",
"changchun989@proton.me": "changchun989",
"jefferson@heimdallstrategy.com": "Mind-Dragon",
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
"steve.westerhouse@origami-analytics.com": "westers",
@@ -92,6 +241,8 @@ AUTHOR_MAP = {
"130918800+devorun@users.noreply.github.com": "devorun",
"surat.s@itm.kmutnb.ac.th": "beesrsj2500",
"beesr@bee.localdomain": "beesrsj2500",
"mind-dragon@nous.research": "Mind-Dragon",
"juntingpublic@gmail.com": "JustinUssuri",
"mtf201013@gmail.com": "ma-pony",
"sonoyuncudmr@gmail.com": "Sonoyunchu",
"43525405+yatesjalex@users.noreply.github.com": "yatesjalex",
@@ -100,11 +251,18 @@ AUTHOR_MAP = {
"web3blind@users.noreply.github.com": "web3blind",
"julia@alexland.us": "alexg0bot",
"christian@scheid.tech": "scheidti",
# Moonshot schema anyOf+enum salvage (May 2026)
"git@local.invalid": "hendrixfreire",
"1060770+benjaminsehl@users.noreply.github.com": "benjaminsehl",
"nerijusn76@gmail.com": "Nerijusas",
# Compaction salvage batch (May 2026)
"MacroAnarchy@users.noreply.github.com": "MacroAnarchy",
"itonov@proton.me": "Ito-69",
"glesstech@gmail.com": "georgeglessner",
"maxim.smetanin@gmail.com": "maxims-oss",
# Codex Spark restoration salvage (May 2026)
"olegwn@gmail.com": "nederev",
"vesper@askclaw.dev": "askclaw-vesper",
"nazirulhafiy@gmail.com": "nazirulhafiy",
"CREWorx@users.noreply.github.com": "BadTechBandit",
"yoimexex@gmail.com": "Yoimex",
@@ -112,6 +270,7 @@ AUTHOR_MAP = {
"foxion37@gmail.com": "foxion37",
"bloodcarter@gmail.com": "bloodcarter",
"scott@scotttrinh.com": "scotttrinh",
"quocanh261997@gmail.com": "quocanh261997",
# contributors (from noreply pattern)
"david.vv@icloud.com": "davidvv",
"wangqiang@wangqiangdeMac-mini.local": "xiaoqiang243",
@@ -162,11 +321,14 @@ AUTHOR_MAP = {
"104278804+Sertug17@users.noreply.github.com": "Sertug17",
"112503481+caentzminger@users.noreply.github.com": "caentzminger",
"258577966+voidborne-d@users.noreply.github.com": "voidborne-d",
"3820588+ddupont808@users.noreply.github.com": "ddupont808",
"liusway405@gmail.com": "voidborne-d",
"xydarcher@uestc.edu.cn": "Readon",
"sir_even@icloud.com": "sirEven",
"36056348+sirEven@users.noreply.github.com": "sirEven",
"70424851+insecurejezza@users.noreply.github.com": "insecurejezza",
"jezzahehn@gmail.com": "JezzaHehn",
"barnacleboy.jezzahehn@agentmail.to": "JezzaHehn",
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
@@ -182,6 +344,7 @@ AUTHOR_MAP = {
"nish3451@users.noreply.github.com": "nish3451",
"Mibayy@users.noreply.github.com": "Mibayy",
"mibayy@users.noreply.github.com": "Mibayy",
"mibay@clawhub.io": "Mibayy",
"135070653+sgaofen@users.noreply.github.com": "sgaofen",
"lzy.dev@gmail.com": "zhiyanliu",
"me@janstepanovsky.cz": "hhhonzik",
@@ -236,6 +399,7 @@ AUTHOR_MAP = {
"hakanerten02@hotmail.com": "teyrebaz33",
"linux2010@users.noreply.github.com": "Linux2010",
"elmatadorgh@users.noreply.github.com": "elmatadorgh",
"coktinbaran5@gmail.com": "elmatadorgh",
"alexazzjjtt@163.com": "alexzhu0",
"1180176+Swift42@users.noreply.github.com": "Swift42",
"ruzzgarcn@gmail.com": "Ruzzgar",
@@ -292,6 +456,7 @@ AUTHOR_MAP = {
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
"mgparkprint@gmail.com": "vlwkaos",
"1317078257maroon@gmail.com": "Oxidane-bot",
"tranquil_flow@protonmail.com": "Tranquil-Flow",
"LyleLengyel@gmail.com": "mcndjxlefnd",
"wangshengyang2004@163.com": "Wangshengyang2004",
@@ -314,11 +479,14 @@ AUTHOR_MAP = {
"camilo@tekelala.com": "tekelala",
"vincentcharlebois@gmail.com": "vincentcharlebois",
"aryan@synvoid.com": "aryansingh",
"johnsonblake1@gmail.com": "blakejohnson",
"johnsonblake1@gmail.com": "voteblake",
"hcn518@gmail.com": "pedh",
"haileymarshall005@gmail.com": "haileymarshall",
"bennet.yr.wang@gmail.com": "BennetYrWang",
"greer.guthrie@gmail.com": "g-guthrie",
"kennyx102@gmail.com": "bobashopcashier",
"77253505+bobashopcashier@users.noreply.github.com": "bobashopcashier",
"25355950+megastary@users.noreply.github.com": "megastary", # PR #18325
"shokatalishaikh95@gmail.com": "areu01or00",
"bryan@intertwinesys.com": "bryanyoung",
"christo.mitov@gmail.com": "christomitov",
@@ -330,6 +498,7 @@ AUTHOR_MAP = {
"stefan@dimagents.ai": "dimitrovi",
"hermes@noushq.ai": "benbarclay",
"chinmingcock@gmail.com": "ChimingLiu",
"allard.quek@singtel.com": "AllardQuek",
"openclaw@sparklab.ai": "openclaw",
"semihcvlk53@gmail.com": "Himess",
"erenkar950@gmail.com": "erenkarakus",
@@ -346,11 +515,20 @@ AUTHOR_MAP = {
"m@statecraft.systems": "mbierling",
"balyan.sid@gmail.com": "alt-glitch",
"52913345+alt-glitch@users.noreply.github.com": "alt-glitch",
"oluwadareab12@gmail.com": "bennytimz",
"oluwadareab12@gmail.com": "oluwadareab12",
"simon@simonmarcus.org": "simon-marcus",
"xowiekk@gmail.com": "Xowiek",
"1243352777@qq.com": "zons-zhaozhy",
"e.silacandmr@gmail.com": "Es1la",
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"sandrohub013@gmail.com": "SandroHub013",
"maciekczech@users.noreply.github.com": "maciekczech",
"h3057183414@gmail.com": "CoreyNoDream",
"franksong2702@gmail.com": "franksong2702",
"673088860@qq.com": "ambition0802",
"beibei1988@proton.me": "beibi9966",
# ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
# crossref, and GH contributor list matching (April 2026 audit) ──
"1115117931@qq.com": "aaronagent",
@@ -422,6 +600,8 @@ AUTHOR_MAP = {
"ogzerber@users.noreply.github.com": "ogzerber",
"cola-runner@users.noreply.github.com": "cola-runner",
"ygd58@users.noreply.github.com": "ygd58",
"45554392+warabe1122@users.noreply.github.com": "warabe1122",
"187001140+willy-scr@users.noreply.github.com": "willy-scr",
"vominh1919@users.noreply.github.com": "vominh1919",
"iamagenius00@users.noreply.github.com": "iamagenius00",
"9219265+cresslank@users.noreply.github.com": "cresslank",
@@ -430,6 +610,7 @@ AUTHOR_MAP = {
"centripetal-star@users.noreply.github.com": "centripetal-star",
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"cine.dreamer.one@gmail.com": "LeonSGP43",
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
"niyant@spicefi.xyz": "spniyant",
"olafthiele@gmail.com": "olafthiele",
@@ -446,6 +627,7 @@ AUTHOR_MAP = {
"taosiyuan163@153.com": "taosiyuan163",
"tesseracttars@gmail.com": "tesseracttars-creator",
"tianliangjay@gmail.com": "xingkongliang",
"1317078257maroon@gmail.com": "Oxidane-bot",
"tranquil_flow@protonmail.com": "Tranquil-Flow",
"LyleLengyel@gmail.com": "mcndjxlefnd",
"unayung@gmail.com": "Unayung",
@@ -478,6 +660,11 @@ AUTHOR_MAP = {
"michel.belleau@malaiwah.com": "malaiwah",
"gnanasekaran.sekareee@gmail.com": "gnanam1990",
"jz.pentest@gmail.com": "0xyg3n",
"7093928+0xyg3n@users.noreply.github.com": "0xyg3n",
"nftpoetrist@gmail.com": "nftpoetrist", # PR #18982
"millerc79@users.noreply.github.com": "millerc79", # PR #19033
"hermes@example.com": "shellybotmoyer", # PR #18915 (bot-committed)
"exx@example.com": "exxmen", # PR #19555
"hypnosis.mda@gmail.com": "Hypn0sis",
"ywt000818@gmail.com": "OwenYWT",
"dhandhalyabhavik@gmail.com": "v1k22",
@@ -491,12 +678,16 @@ AUTHOR_MAP = {
"hubin_ll@qq.com": "LLQWQ",
"memosr_email@gmail.com": "memosr",
"jperlow@gmail.com": "perlowja",
"jasonpette1783@gmail.com": "web-dev0521",
"bjianhang@gmail.com": "bjianhang",
"tangyuanjc@JCdeAIfenshendeMac-mini.local": "tangyuanjc",
"harryplusplus@gmail.com": "harryplusplus",
"anthhub@163.com": "anthhub",
"allard.quek@singtel.com": "AllardQuek",
"shenuu@gmail.com": "shenuu",
"xiayh17@gmail.com": "xiayh0107",
"zhujianxyz@gmail.com": "opriz",
"tuancanhnguyen706@gmail.com": "xxxigm",
"asurla@nvidia.com": "anniesurla",
"limkuan24@gmail.com": "WideLee",
"aviralarora002@gmail.com": "AviArora02-commits",
@@ -536,6 +727,16 @@ AUTHOR_MAP = {
"chenb19870707@gmail.com": "ms-alan",
"276886827+WuTianyi123@users.noreply.github.com": "WuTianyi123",
"22549957+li0near@users.noreply.github.com": "li0near",
"guoyu801@gmail.com": "li0near",
"ty@tmrtn.com": "tymrtn",
"elitovsky@zenproject.net": "kallidean",
"5463986+baocin@users.noreply.github.com": "baocin",
"107296821+princepal9120@users.noreply.github.com": "princepal9120",
"gufo0125@gmail.com": "guglielmofonda",
"102474490+yehuosi@users.noreply.github.com": "yehuosi",
"yehuosi@users.noreply.github.com": "yehuosi",
"31932854+jelrod27@users.noreply.github.com": "jelrod27",
"11262660+konsisumer@users.noreply.github.com": "konsisumer",
"23434080+sicnuyudidi@users.noreply.github.com": "sicnuyudidi",
"haimu0x0@proton.me": "haimu0x",
"abdelmajidnidnasser1@gmail.com": "NIDNASSER-Abdelmajid",
@@ -557,6 +758,7 @@ AUTHOR_MAP = {
"mike@mikewaters.net": "mikewaters",
"65117428+WadydX@users.noreply.github.com": "WadydX",
"216480837+isaachuangGMICLOUD@users.noreply.github.com": "isaachuangGMICLOUD",
"isaac.h@gmicloud.ai": "isaachuangGMICLOUD",
"nukuom976228@gmail.com": "hsy5571616",
"11462216+Nan93@users.noreply.github.com": "Nan93",
"l973401489@126.com": "zhouxiaoya12",
@@ -587,6 +789,101 @@ AUTHOR_MAP = {
"2114364329@qq.com": "cuyua9",
"2557058999@qq.com": "Disaster-Terminator",
"cine.dreamer.one@gmail.com": "LeonSGP43",
"zyprothh@gmail.com": "Zyproth",
"amitgaur@gmail.com": "amitgaur",
"albuquerque.abner@gmail.com": "mrbob-git",
"kiala@users.noreply.github.com": "kiala9",
"alanxchen@gmail.com": "alanxchen85",
"clawbot@clawbots-Mac-mini.local": "John-tip",
"der@konsi.org": "konsisumer",
"cirwel@The-CIRWEL-Group.local": "CIRWEL",
"molvikar8@gmail.com": "molvikar",
"nftpoetrist@gmail.com": "nftpoetrist",
"dodofun@126.com": "colorcross",
"1615063567@qq.com": "zhao0112",
"ethanguo.2003@gmail.com": "EthanGuo-coder",
"dev0jsh@gmail.com": "tmdgusya",
"leavr@163.com": "leavrcn",
"17683456+wanazhar@users.noreply.github.com": "wanazhar",
"26782336+cixuuz@users.noreply.github.com": "cixuuz",
"aleksandr.pasevin@openzeppelin.com": "pasevin",
"ubuntu@localhost.localdomain": "holynn-q",
"holynn@placeholder.local": "holynn-q",
"agent@hermes.local": "jacdevos",
"sunsky.lau@gmail.com": "liuhao1024",
"qiuqfang98@qq.com": "keepcalmqqf",
"261867348+ai-ag2026@users.noreply.github.com": "ai-ag2026",
"yanzh.su@gmail.com": "YanzhongSu",
"wanderwang@users.noreply.github.com": "WanderWang",
"yueheime@gmail.com": "yuehei",
"emidomh@gmail.com": "Emidomenge",
"2642448440@qq.com": "BlackJulySnow",
"4317663+helix4u@users.noreply.github.com": "helix4u",
"floptopbot33@gmail.com": "flobo3",
"dpaluy@users.noreply.github.com": "dpaluy",
"psikonetik@gmail.com": "el-analista",
"chenb19870707@gmail.com": "ms-alan",
"hex-clawd@users.noreply.github.com": "hex-clawd",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"barteq@hacknotes.local": "barteqpl",
"pama0227@gmail.com": "pama0227",
"52785845+ee-blog@users.noreply.github.com": "ee-blog",
"simplenamebox@gmail.com": "simplenamebox-ops",
"balyan.sid@gmail.com": "alt-glitch",
"xdord@xdorddeMac-mini.local": "foreverxdord",
"k2767567815@gmail.com": "QifengKuang",
"88077783+jjjojoj@users.noreply.github.com": "jjjojoj",
"valda@underscore.jp": "valda",
"lling486@163.com": "M3RCUR2Y",
"buraysandro9@gmail.com": "ygd58",
"ideathinklab01-source@users.noreply.github.com": "ideathinklab01-source",
"27987889@qq.com": "zng8418",
"daniuxie88@proton.me": "DaniuXie",
"panchanler@gmail.com": "ChanlerDev",
"252620095+briandevans@users.noreply.github.com": "briandevans",
"141889580+h0tp-ftw@users.noreply.github.com": "h0tp-ftw",
"chinadbo@foxmail.com": "chinadbo",
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
"xyywtt@gmail.com": "xyiy001",
"charliekerfoot@gmail.com": "CharlieKerfoot",
"grey0202@users.noreply.github.com": "Grey0202",
"vominh1919@gmail.com": "vominh1919",
"giwavictor9@gmail.com": "giwaov",
"yoimexex@gmail.com": "Yoimex",
"76803960+atongrun@users.noreply.github.com": "atongrun",
"michaeldanko@icloud.com": "MichaelWDanko",
"xudavid429@gmail.com": "YX234",
"kathy@Kathy.local": "julysir",
"274902531@qq.com": "JanCong",
"225304168+e-shizz@users.noreply.github.com": "e-shizz",
"vincent_hh@users.noreply.github.com": "VinVC",
"1243352777@qq.com": "zons-zhaozhy",
"dejie.guo@gmail.com": "JayGwod",
"52840391+swithek@users.noreply.github.com": "swithek",
"raipratik0101@gmail.com": "PratikRai0101",
"code@sasha.id": "sasha-id",
"chen.yunbo@xydigit.com": "chenyunbo411",
"openclaw@local": "Asce66",
"59465365+0xsir0000@users.noreply.github.com": "0xsir0000",
"lisanhu2014@hotmail.com": "lisanhu",
"0668001438@zte.com.cn": "chenyunbo411",
"steven_chanin@alum.mit.edu": "stevenchanin",
"fiver@example.com": "halmisen",
"mayq0422@gmail.com": "yuqianma",
"yuqian@zmetasoft.com": "yuqianma",
"scott@bubble.local": "bassings",
"highland0971@users.noreply.github.com": "highland0971",
"sudolewis@gmail.com": "lewislulu",
"gaurav2301v@gmail.com": "Gaurav23V",
"tranquil_flow@protonmail.com": "Tranquil-Flow",
"albert748@gmail.com": "albert748",
"ntconguit@gmail.com": "0xharryriddle",
"lhysdl@gmail.com": "lhysdl",
"shemol@163.com": "SherlockShemol",
"enochlam2002@gmail.com": "eloklam",
"eloklam@eloklam-ubuntudesktop.tail21966c.ts.net": "eloklam",
"clawdia@fmercurio-macstudio.local": "fmercurio",
"ricardoporsche001@icloud.com": "Ricardo-M-L",
"leozeli@qq.com": "leozeli",
"linlehao@cuhk.edu.cn": "LehaoLin",
"liutong@isacas.ac.cn": "I3eg1nner",
@@ -644,6 +941,47 @@ AUTHOR_MAP = {
"web3blind@gmail.com": "web3blind",
"ztzheng@163.com": "chengoak", # PR #17467
"24110240104@m.fudan.edu.cn": "YuShu", # co-author only
"charliekerfoot@gmail.com": "CharlieKerfoot", # PR #18951
# Debug share upload-time redaction (May 2026)
"dhuysamen@gmail.com": "GodsBoy", # PR #19318
"mrcoferland@gmail.com": "mrcoferland", # PR #19023
"chenlinfeng@ruije.com.cn": "noOne-list", # PR #19050
"briansu@Mac-mini.attlocal.net": "likejudy", # PR #19052
"leosma@gmail.com": "leon7609", # PR #19069
"nouseman666@gmail.com": "nouseman666", # PR #19088
"ginwu05@gmail.com": "GinWU05", # PR #19093
"shashwatgokhe2@gmail.com": "shashwatgokhe", # PR #19196
"stevenchou.ai@gmail.com": "stevenchouai", # PR #19221
"leo.gong@phizchat.com": "agilejava", # PR #19346
"acc001k@pm.me": "acc001k", # PR #19358
"kowenhao@users.noreply.github.com": "kowenhaoai", # PR #19376
"hedirman@gmail.com": "hedirman", # PR #19410
"lucianopacheco@gmail.com": "LucianoSP", # PR #19412
"paultian.research@gmail.com": "paul-tian", # PR #19423
"info@glesperance.com": "glesperance", # PR #19443
"lxl694522264@gmail.com": "EvilDrag0n", # PR #20651
# v0.13.0 additions
"clode@clo5de.info": "jackey8616", # via PR salvage
"james.russo@heygen.com": "jrusso1020", # via PR salvage
"leon@sgp43.com": "LeonSGP43", # PR #18739 salvage of #14570
"miniding@miniding.home": "Foolafroos", # PR #20329 French locale
"montbra@gmail.com": "Montbra", # PR #20897 salvage of #16189 (TUI voice PTT)
"promptsiren@gmail.com": "firefly", # PR #18123 salvage of #16660 (ContextVars)
"wtyopenclaw@gmail.com": "WuTianyi123", # PR #20275 salvage of #13723 (feishu markdown)
"zhicheng.han@mathematik.uni-goettingen.de": "hanzckernel", # PR #20311 (api-server approval events)
"agentsmithlaor@gmail.com": "oferlaor", # PR #22356 salvage (cron origin sender identity)
"jhin.lee@unity3d.com": "leehack", # PR #22053 salvage (telegram DM topic reply fallback)
# pander: empty email, salvaged via PR #19665 from #16126 by @ms-alan
"ayman.a.kamal@hotmail.com": "A-kamal", # PR #18678 (xAI image resolution fix)
# Kanban bug-fix batch salvage (May 2026)
"frowte3k@gmail.com": "Frowtek", # salvage of #23206 (gateway --board auto-subscribe)
"sylw3st3rr@gmail.com": "Sylw3ster", # salvage of #23252 (HERMES_KANBAN_BOARD restore)
"hello@dominikh.com": "dmnkhorvath", # salvage of #23358 (kanban worker send_message)
"413011+smwbev@users.noreply.github.com": "smwbev", # salvage of #23659 (aria-label colLabel)
"58116817+TurgutKural@users.noreply.github.com": "TurgutKural", # salvage of #23356 (HERMES_HOME inject)
"openclaw@agent.local": "29206394", # PR #22194 salvage (sudo -S brute-force guard, #9590)
"freedemon@gmail.com": "fr33d3m0n", # PR #21128 salvage (sudo stdin/askpass DANGEROUS, #17873 cat 4)
"zhaowh3613@outlook.com": "VinceZcrikl", # PR #23647 salvage (npm UTF-8 decode on GBK Windows)
}
@@ -1088,7 +1426,7 @@ def main():
print(f" SemVer: v{current_version} → v{new_version}")
print(f" Previous tag: {prev_tag or '(none — first release)'}")
print(f" Commits: {len(commits)}")
print(f" Unique authors: {len(set(c['github_author'] for c in commits))}")
print(f" Unique authors: {len({c['github_author'] for c in commits})}")
print(f" Mode: {'PUBLISH' if args.publish else 'DRY RUN'}")
print(f"{'='*60}")
print()
@@ -1101,7 +1439,7 @@ def main():
)
if args.output:
Path(args.output).write_text(changelog)
Path(args.output).write_text(changelog, encoding="utf-8")
print(f"Changelog written to {args.output}")
else:
print(changelog)

View File

@@ -44,7 +44,15 @@ PYTHON="$VENV/bin/python"
# ── Ensure pytest-split is installed (required for shard-equivalent runs) ──
if ! "$PYTHON" -c "import pytest_split" 2>/dev/null; then
echo "→ installing pytest-split into $VENV"
"$PYTHON" -m pip install --quiet "pytest-split>=0.9,<1"
if command -v uv >/dev/null 2>&1; then
uv pip install --python "$PYTHON" --quiet "pytest-split>=0.9,<1"
elif "$PYTHON" -m pip --version >/dev/null 2>&1; then
"$PYTHON" -m pip install --quiet "pytest-split>=0.9,<1"
else
echo "error: neither uv nor pip is available in $VENV — pytest-split is missing" >&2
echo " fix: run uv pip install -e \".[dev]\" from $REPO_ROOT" >&2
exit 1
fi
fi
# ── Hermetic environment ────────────────────────────────────────────────────
@@ -67,6 +75,7 @@ unset HERMES_YOLO_MODE HERMES_INTERACTIVE HERMES_QUIET HERMES_TOOL_PROGRESS \
HERMES_TOOL_PROGRESS_MODE HERMES_MAX_ITERATIONS HERMES_SESSION_PLATFORM \
HERMES_SESSION_CHAT_ID HERMES_SESSION_CHAT_NAME HERMES_SESSION_THREAD_ID \
HERMES_SESSION_SOURCE HERMES_SESSION_KEY HERMES_GATEWAY_SESSION \
HERMES_CRON_SESSION \
HERMES_PLATFORM HERMES_INFERENCE_PROVIDER HERMES_MANAGED HERMES_DEV \
HERMES_CONTAINER HERMES_EPHEMERAL_SYSTEM_PROMPT HERMES_TIMEZONE \
HERMES_REDACT_SECRETS HERMES_BACKGROUND_NOTIFICATIONS HERMES_EXEC_ASK \
@@ -78,6 +87,22 @@ export LANG=C.UTF-8
export LC_ALL=C.UTF-8
export PYTHONHASHSEED=0
# ── Live-gateway test guard (developer machines) ────────────────────────────
# If a system-wide hermes pytest_live_guard plugin is installed at
# $HOME/.hermes/pytest_live_guard.py, force-load it here so every test run
# from this script gets the protection regardless of which worktree is
# checked out (in-tree tests/conftest.py guard may be missing on stale
# branches). Harmless on CI / fresh machines that don't have the file.
if [ -f "$HOME/.hermes/pytest_live_guard.py" ]; then
case ":${PYTHONPATH:-}:" in
*":$HOME/.hermes:"*) ;;
*) export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$HOME/.hermes" ;;
esac
if [[ ",${PYTEST_PLUGINS:-}," != *,pytest_live_guard,* ]]; then
export PYTEST_PLUGINS="${PYTEST_PLUGINS:+$PYTEST_PLUGINS,}pytest_live_guard"
fi
fi
# ── Worker count ────────────────────────────────────────────────────────────
# CI uses `-n auto` on ubuntu-latest which gives 4 workers. A 20-core
# workstation with `-n auto` gets 20 workers and exposes test-ordering

349
scripts/setup_open_webui.sh Executable file
View File

@@ -0,0 +1,349 @@
#!/usr/bin/env bash
set -euo pipefail
# Bootstrap Open WebUI against Hermes Agent's OpenAI-compatible API server.
#
# Idempotent by design:
# - ensures ~/.hermes/.env has API server settings
# - installs Open WebUI into ~/.local/open-webui-venv
# - writes a reusable launcher at ~/.local/bin/start-open-webui-hermes.sh
# - optionally installs a user service (launchd on macOS, systemd --user on Linux)
#
# Usage:
# bash scripts/setup_open_webui.sh
#
# Optional environment overrides:
# OPEN_WEBUI_PORT=8080
# OPEN_WEBUI_HOST=127.0.0.1
# OPEN_WEBUI_NAME='Johnny Hermes'
# OPEN_WEBUI_ENABLE_SIGNUP=true
# OPEN_WEBUI_ENABLE_SERVICE=auto # auto|true|false
# OPEN_WEBUI_VENV=~/.local/open-webui-venv
# OPEN_WEBUI_DATA_DIR=~/.local/share/open-webui/data
# HERMES_API_PORT=8642
# HERMES_API_HOST=127.0.0.1
# HERMES_API_MODEL_NAME='Hermes Agent'
OPEN_WEBUI_PORT="${OPEN_WEBUI_PORT:-8080}"
OPEN_WEBUI_HOST="${OPEN_WEBUI_HOST:-127.0.0.1}"
OPEN_WEBUI_NAME="${OPEN_WEBUI_NAME:-Hermes Agent WebUI}"
OPEN_WEBUI_ENABLE_SIGNUP="${OPEN_WEBUI_ENABLE_SIGNUP:-true}"
OPEN_WEBUI_ENABLE_SERVICE="${OPEN_WEBUI_ENABLE_SERVICE:-auto}"
OPEN_WEBUI_VENV="${OPEN_WEBUI_VENV:-$HOME/.local/open-webui-venv}"
OPEN_WEBUI_DATA_DIR="${OPEN_WEBUI_DATA_DIR:-$HOME/.local/share/open-webui/data}"
HERMES_ENV_FILE="${HERMES_ENV_FILE:-$HOME/.hermes/.env}"
HERMES_API_PORT="${HERMES_API_PORT:-8642}"
HERMES_API_HOST="${HERMES_API_HOST:-127.0.0.1}"
HERMES_API_CONNECT_HOST="${HERMES_API_CONNECT_HOST:-127.0.0.1}"
HERMES_API_MODEL_NAME="${HERMES_API_MODEL_NAME:-Hermes Agent}"
HERMES_API_BASE_URL="http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/v1"
LAUNCHER_PATH="$HOME/.local/bin/start-open-webui-hermes.sh"
LOG_DIR="$HOME/.hermes/logs"
log() {
printf '[open-webui-bootstrap] %s\n' "$*"
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
choose_python() {
if command -v python3.11 >/dev/null 2>&1; then
echo python3.11
elif command -v python3 >/dev/null 2>&1; then
echo python3
else
echo "Python 3 is required." >&2
exit 1
fi
}
upsert_env() {
local key="$1"
local value="$2"
local file="$3"
mkdir -p "$(dirname "$file")"
touch "$file"
python3 - "$file" "$key" "$value" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
key = sys.argv[2]
value = sys.argv[3]
lines = path.read_text().splitlines() if path.exists() else []
out = []
seen = False
for raw in lines:
stripped = raw.strip()
if stripped.startswith(f"{key}="):
if not seen:
out.append(f"{key}={value}")
seen = True
continue
out.append(raw)
if not seen:
if out and out[-1] != "":
out.append("")
out.append(f"{key}={value}")
path.write_text("\n".join(out).rstrip() + "\n")
PY
}
get_env_value() {
local key="$1"
local file="$2"
python3 - "$file" "$key" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
key = sys.argv[2]
if not path.exists():
raise SystemExit(0)
for raw in path.read_text().splitlines():
line = raw.strip()
if line.startswith(f"{key}="):
print(line.split("=", 1)[1])
raise SystemExit(0)
PY
}
generate_secret() {
python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(32))
PY
}
shell_quote() {
python3 - "$1" <<'PY'
import shlex
import sys
print(shlex.quote(sys.argv[1]))
PY
}
can_use_systemd_user() {
[[ "$(uname -s)" == "Linux" ]] || return 1
command -v systemctl >/dev/null 2>&1 || return 1
local uid runtime_dir bus_path
uid="$(id -u)"
runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$uid}"
bus_path="$runtime_dir/bus"
if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "$runtime_dir" ]]; then
export XDG_RUNTIME_DIR="$runtime_dir"
fi
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "$bus_path" ]]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=$bus_path"
fi
systemctl --user show-environment >/dev/null 2>&1
}
install_macos_dependencies() {
if [[ "$(uname -s)" == "Darwin" ]] && command -v brew >/dev/null 2>&1; then
if ! command -v pandoc >/dev/null 2>&1; then
log 'Installing pandoc with Homebrew (recommended by Open WebUI docs)...'
brew install pandoc
fi
fi
}
install_open_webui() {
local py
py="$(choose_python)"
log "Using Python interpreter: $py"
"$py" -m venv "$OPEN_WEBUI_VENV"
# shellcheck disable=SC1090
source "$OPEN_WEBUI_VENV/bin/activate"
python -m pip install --upgrade pip setuptools wheel
python -m pip install open-webui
}
write_launcher() {
mkdir -p "$(dirname "$LAUNCHER_PATH")" "$OPEN_WEBUI_DATA_DIR" "$LOG_DIR"
local quoted_data_dir quoted_name quoted_base_url quoted_host quoted_port quoted_venv
quoted_data_dir="$(shell_quote "$OPEN_WEBUI_DATA_DIR")"
quoted_name="$(shell_quote "$OPEN_WEBUI_NAME")"
quoted_base_url="$(shell_quote "$HERMES_API_BASE_URL")"
quoted_host="$(shell_quote "$OPEN_WEBUI_HOST")"
quoted_port="$(shell_quote "$OPEN_WEBUI_PORT")"
quoted_venv="$(shell_quote "$OPEN_WEBUI_VENV")"
cat > "$LAUNCHER_PATH" <<EOF
#!/usr/bin/env bash
set -euo pipefail
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
API_KEY=\$(python3 - <<'PY'
from pathlib import Path
p = Path.home()/'.hermes'/'.env'
for raw in p.read_text().splitlines():
line = raw.strip()
if line.startswith('API_SERVER_KEY='):
print(line.split('=', 1)[1])
break
PY
)
export DATA_DIR=${quoted_data_dir}
export WEBUI_NAME=${quoted_name}
export ENABLE_SIGNUP=${OPEN_WEBUI_ENABLE_SIGNUP}
export ENABLE_PUBLIC_ACTIVE_USERS_COUNT=False
export ENABLE_VERSION_UPDATE_CHECK=False
export OPENAI_API_BASE_URL=${quoted_base_url}
export OPENAI_API_KEY="\$API_KEY"
export ENABLE_OPENAI_API=True
export ENABLE_OLLAMA_API=False
export OFFLINE_MODE=True
export BYPASS_EMBEDDING_AND_RETRIEVAL=True
export RAG_EMBEDDING_MODEL_AUTO_UPDATE=False
export RAG_RERANKING_MODEL_AUTO_UPDATE=False
export SCARF_NO_ANALYTICS=true
export DO_NOT_TRACK=true
export ANONYMIZED_TELEMETRY=false
export HOST=${quoted_host}
export PORT=${quoted_port}
source ${quoted_venv}/bin/activate
exec open-webui serve
EOF
chmod +x "$LAUNCHER_PATH"
}
ensure_env_permissions() {
chmod 600 "$HERMES_ENV_FILE" 2>/dev/null || true
}
install_launchd_service() {
local plist="$HOME/Library/LaunchAgents/ai.openwebui.hermes.plist"
mkdir -p "$(dirname "$plist")"
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openwebui.hermes</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${LAUNCHER_PATH}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>${HOME}</string>
<key>StandardOutPath</key>
<string>${LOG_DIR}/openwebui.log</string>
<key>StandardErrorPath</key>
<string>${LOG_DIR}/openwebui.error.log</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)" "$plist" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$plist"
launchctl enable "gui/$(id -u)/ai.openwebui.hermes"
launchctl kickstart -k "gui/$(id -u)/ai.openwebui.hermes"
}
install_systemd_user_service() {
require_cmd systemctl
local unit_dir="$HOME/.config/systemd/user"
local unit="$unit_dir/openwebui-hermes.service"
mkdir -p "$unit_dir"
cat > "$unit" <<EOF
[Unit]
Description=Open WebUI connected to Hermes Agent
After=default.target
[Service]
Type=simple
ExecStart=/bin/bash %h/.local/bin/start-open-webui-hermes.sh
Restart=always
RestartSec=3
WorkingDirectory=%h
StandardOutput=append:%h/.hermes/logs/openwebui.log
StandardError=append:%h/.hermes/logs/openwebui.error.log
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now openwebui-hermes.service
}
start_foreground_hint() {
log "Launcher created at: ${LAUNCHER_PATH}"
log "Start Open WebUI manually with: ${LAUNCHER_PATH}"
}
main() {
require_cmd hermes
require_cmd curl
require_cmd python3
install_macos_dependencies
local api_key
api_key="$(get_env_value API_SERVER_KEY "$HERMES_ENV_FILE")"
if [[ -z "$api_key" ]]; then
api_key="$(generate_secret)"
fi
log 'Ensuring Hermes API server is configured...'
upsert_env API_SERVER_ENABLED true "$HERMES_ENV_FILE"
upsert_env API_SERVER_HOST "$HERMES_API_HOST" "$HERMES_ENV_FILE"
upsert_env API_SERVER_PORT "$HERMES_API_PORT" "$HERMES_ENV_FILE"
upsert_env API_SERVER_MODEL_NAME "$HERMES_API_MODEL_NAME" "$HERMES_ENV_FILE"
upsert_env API_SERVER_KEY "$api_key" "$HERMES_ENV_FILE"
ensure_env_permissions
log 'Restarting Hermes gateway so API server settings take effect...'
hermes gateway restart >/dev/null 2>&1 || true
sleep 4
if ! curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null; then
log 'Hermes API server did not answer on the first check. Trying to start gateway in the background...'
nohup hermes gateway run >/dev/null 2>&1 &
sleep 6
fi
curl -fsS "http://${HERMES_API_CONNECT_HOST}:${HERMES_API_PORT}/health" >/dev/null
log 'Installing Open WebUI into a dedicated virtualenv...'
install_open_webui
write_launcher
case "$OPEN_WEBUI_ENABLE_SERVICE" in
true|auto)
if [[ "$(uname -s)" == "Darwin" ]]; then
install_launchd_service
elif can_use_systemd_user; then
install_systemd_user_service
else
log 'No usable user service manager detected; falling back to the launcher script.'
start_foreground_hint
fi
;;
false)
start_foreground_hint
;;
*)
echo "OPEN_WEBUI_ENABLE_SERVICE must be one of: auto, true, false" >&2
exit 1
;;
esac
log "Done. Open WebUI should be available at: http://${OPEN_WEBUI_HOST}:${OPEN_WEBUI_PORT}"
log "Hermes API endpoint: ${HERMES_API_BASE_URL}"
log 'Important: Open WebUI persists connection settings after first launch. If you later save a wrong API key in the Admin UI, update/delete that connection there or reset its database.'
}
main "$@"

View File

@@ -64,8 +64,12 @@ export function expandWhatsAppIdentifiers(identifier, sessionDir) {
}
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
// Empty allowlist = NO ONE allowed (secure default, #8389). Operators
// who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
// Previous behaviour (empty → return true) let any stranger DM the
// bridge and trigger a Python-side pairing-code reply.
if (!allowedUsers || allowedUsers.size === 0) {
return true;
return false;
}
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)

View File

@@ -57,3 +57,24 @@ test('matchesAllowedUser treats * as allow-all wildcard', () => {
rmSync(sessionDir, { recursive: true, force: true });
}
});
test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
// Regression guard: empty allowlist used to return true (allow-everyone),
// which let any stranger DM the bridge and trigger a Python-side
// pairing-code reply. Secure default is now "reject unless explicitly
// configured"; operators who want an open bot must set `*`.
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
const empty = parseAllowedUsers('');
assert.equal(empty.size, 0);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
// Null/undefined allowlist (defensive) also rejects.
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});

View File

@@ -23,8 +23,10 @@ import express from 'express';
import { Boom } from '@hapi/boom';
import pino from 'pino';
import path from 'path';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { randomBytes } from 'crypto';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import qrcode from 'qrcode-terminal';
import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js';
@@ -53,6 +55,12 @@ const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n──────────
const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined
? DEFAULT_REPLY_PREFIX
: process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n');
const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10);
const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10);
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function formatOutgoingMessage(message) {
// In bot mode, messages come from a different number so the prefix is
@@ -62,6 +70,38 @@ function formatOutgoingMessage(message) {
return REPLY_PREFIX ? `${REPLY_PREFIX}${message}` : message;
}
function splitLongMessage(message, maxLength = MAX_MESSAGE_LENGTH) {
const text = String(message || '');
if (!text) return [];
if (!Number.isFinite(maxLength) || maxLength < 1 || text.length <= maxLength) {
return [text];
}
const chunks = [];
let remaining = text;
while (remaining.length > maxLength) {
let splitAt = remaining.lastIndexOf('\n', maxLength);
if (splitAt < Math.floor(maxLength / 2)) {
splitAt = remaining.lastIndexOf(' ', maxLength);
}
if (splitAt < 1) splitAt = maxLength;
chunks.push(remaining.slice(0, splitAt).trimEnd());
remaining = remaining.slice(splitAt).trimStart();
}
if (remaining) chunks.push(remaining);
return chunks;
}
function trackSentMessageId(sent) {
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);
if (recentlySentIds.size > MAX_RECENT_IDS) {
recentlySentIds.delete(recentlySentIds.values().next().value);
}
}
}
function normalizeWhatsAppId(value) {
if (!value) return '';
return String(value).replace(':', '@');
@@ -227,17 +267,34 @@ async function startSocket() {
if (!isSelfChat) continue;
}
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'allowlist_mismatch',
chatId,
senderId,
}));
} catch {}
continue;
// Handle !fromMe messages (from other people) based on mode.
// Self-chat mode only responds to the user's own messages to
// themselves — stranger DMs / group pings must never reach the
// Python gateway, otherwise a pairing-code reply fires in response
// to arbitrary incoming messages (#8389).
if (!msg.key.fromMe) {
if (WHATSAPP_MODE === 'self-chat') {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'self_chat_mode_rejects_non_self',
chatId,
senderId,
}));
} catch {}
continue;
}
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'allowlist_mismatch',
chatId,
senderId,
}));
} catch {}
continue;
}
}
const messageContent = getMessageContent(msg);
@@ -421,17 +478,22 @@ app.post('/send', async (req, res) => {
}
try {
const sent = await sock.sendMessage(chatId, { text: formatOutgoingMessage(message) });
// Track sent message ID to prevent echo-back loops
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);
if (recentlySentIds.size > MAX_RECENT_IDS) {
recentlySentIds.delete(recentlySentIds.values().next().value);
const chunks = splitLongMessage(formatOutgoingMessage(message));
const messageIds = [];
for (let i = 0; i < chunks.length; i += 1) {
const sent = await sock.sendMessage(chatId, { text: chunks[i] });
trackSentMessageId(sent);
if (sent?.key?.id) messageIds.push(sent.key.id);
if (chunks.length > 1 && i < chunks.length - 1) {
await sleep(CHUNK_DELAY_MS);
}
}
res.json({ success: true, messageId: sent?.key?.id });
res.json({
success: true,
messageId: messageIds[messageIds.length - 1],
messageIds,
});
} catch (err) {
res.status(500).json({ error: err.message });
}
@@ -450,8 +512,22 @@ app.post('/edit', async (req, res) => {
try {
const key = { id: messageId, fromMe: true, remoteJid: chatId };
await sock.sendMessage(chatId, { text: formatOutgoingMessage(message), edit: key });
res.json({ success: true });
const chunks = splitLongMessage(formatOutgoingMessage(message));
const messageIds = [];
await sock.sendMessage(chatId, { text: chunks[0], edit: key });
if (chunks.length > 1) {
for (let i = 1; i < chunks.length; i += 1) {
const sent = await sock.sendMessage(chatId, { text: chunks[i] });
trackSentMessageId(sent);
if (sent?.key?.id) messageIds.push(sent.key.id);
if (i < chunks.length - 1) {
await sleep(CHUNK_DELAY_MS);
}
}
}
res.json({ success: true, messageIds });
} catch (err) {
res.status(500).json({ error: err.message });
}
@@ -505,8 +581,31 @@ app.post('/send-media', async (req, res) => {
msgPayload = { video: buffer, caption: caption || undefined, mimetype: MIME_MAP[ext] || 'video/mp4' };
break;
case 'audio': {
const audioMime = (ext === 'ogg' || ext === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
msgPayload = { audio: buffer, mimetype: audioMime, ptt: ext === 'ogg' || ext === 'opus' };
// WhatsApp only renders a native voice bubble (ptt) when the file is ogg/opus.
// If the caller passes mp3, wav, m4a etc. (e.g. from Edge TTS / NeuTTS),
// silently convert to ogg/opus via ffmpeg so ptt is always honoured.
let audioBuffer = buffer;
let audioExt = ext;
const needsConversion = !['ogg', 'opus'].includes(ext);
let tmpPath = null;
if (needsConversion) {
tmpPath = path.join(tmpdir(), `hermes_voice_${randomBytes(6).toString('hex')}.ogg`);
try {
execSync(
`ffmpeg -y -i ${JSON.stringify(filePath)} -ar 48000 -ac 1 -c:a libopus ${JSON.stringify(tmpPath)}`,
{ timeout: 30000, stdio: 'pipe' }
);
audioBuffer = readFileSync(tmpPath);
audioExt = 'ogg';
} catch (convErr) {
// ffmpeg not available or conversion failed — fall back to original format
console.warn('[bridge] ffmpeg conversion failed, sending as file attachment:', convErr.message);
} finally {
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch (_) {}
}
}
const audioMime = (audioExt === 'ogg' || audioExt === 'opus') ? 'audio/ogg; codecs=opus' : 'audio/mpeg';
msgPayload = { audio: audioBuffer, mimetype: audioMime, ptt: audioExt === 'ogg' || audioExt === 'opus' };
break;
}
case 'document':
@@ -522,13 +621,7 @@ app.post('/send-media', async (req, res) => {
const sent = await sock.sendMessage(chatId, msgPayload);
// Track sent message ID to prevent echo-back loops
if (sent?.key?.id) {
recentlySentIds.add(sent.key.id);
if (recentlySentIds.size > MAX_RECENT_IDS) {
recentlySentIds.delete(recentlySentIds.values().next().value);
}
}
trackSentMessageId(sent);
res.json({ success: true, messageId: sent?.key?.id });
} catch (err) {
@@ -600,8 +693,12 @@ if (PAIR_ONLY) {
console.log(`📁 Session stored in: ${SESSION_DIR}`);
if (ALLOWED_USERS.size > 0) {
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
} else if (WHATSAPP_MODE === 'self-chat') {
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
} else {
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
console.log(` or WHATSAPP_ALLOWED_USERS=* for an explicit open bot.`);
}
console.log();
startSocket();

View File

@@ -25,15 +25,15 @@
}
},
"node_modules/@cacheable/memory": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
"integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
"integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.3.3",
"@keyv/bigmap": "^1.3.0",
"hookified": "^1.14.0",
"keyv": "^5.5.5"
"@cacheable/utils": "^2.4.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/node-cache": {
@@ -51,19 +51,19 @@
}
},
"node_modules/@cacheable/utils": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz",
"integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz",
"integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==",
"license": "MIT",
"dependencies": {
"hashery": "^1.3.0",
"hashery": "^1.5.1",
"keyv": "^5.6.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"peer": true,
@@ -87,9 +87,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -617,9 +617,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
@@ -645,9 +645,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -663,9 +663,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@tokenizer/inflate": {
@@ -714,25 +714,20 @@
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz",
"integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==",
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~7.19.0"
}
},
"node_modules/@whiskeysockets/baileys": {
"name": "baileys",
"version": "7.0.0-rc.9",
"resolved": "git+ssh://git@github.com/WhiskeySockets/Baileys.git#01047debd81beb20da7b7779b08edcb06aa03770",
"integrity": "sha512-letWyB96JHD6NdqpAiseOfaUBi13u8AhiRcKSRqcVjc5Vw5xoPTZGvVnw8K/NvGBFAvyLJkwim9Mjvwzhx/SlA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -807,9 +802,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -820,7 +815,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -830,6 +825,21 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -840,16 +850,16 @@
}
},
"node_modules/cacheable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
"integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz",
"integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==",
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.0.7",
"@cacheable/utils": "^2.3.3",
"@cacheable/memory": "^2.0.8",
"@cacheable/utils": "^2.4.0",
"hookified": "^1.15.0",
"keyv": "^5.5.5",
"qified": "^0.6.0"
"keyv": "^5.6.0",
"qified": "^0.9.0"
}
},
"node_modules/call-bind-apply-helpers": {
@@ -1212,21 +1222,21 @@
}
},
"node_modules/hashery": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
"integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
"license": "MIT",
"dependencies": {
"hookified": "^1.14.0"
"hookified": "^1.15.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1327,44 +1337,6 @@
"protobufjs": "6.8.8"
}
},
"node_modules/libsignal/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/libsignal/node_modules/long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"license": "Apache-2.0"
},
"node_modules/libsignal/node_modules/protobufjs": {
"version": "6.8.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
"integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/long": "^4.0.0",
"@types/node": "^10.1.0",
"long": "^4.0.0"
},
"bin": {
"pbjs": "bin/pbjs",
"pbts": "bin/pbts"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -1372,9 +1344,9 @@
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -1552,12 +1524,12 @@
}
},
"node_modules/p-queue": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {
@@ -1648,22 +1620,22 @@
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
@@ -1685,17 +1657,23 @@
}
},
"node_modules/qified": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
"integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz",
"integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==",
"license": "MIT",
"dependencies": {
"hookified": "^1.14.0"
"hookified": "^2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qified/node_modules/hookified": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"license": "MIT"
},
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
@@ -1922,13 +1900,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -2094,9 +2072,9 @@
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -2139,9 +2117,9 @@
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -12,5 +12,8 @@
"express": "^4.21.0",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"overrides": {
"protobufjs": "^7.5.5"
}
}