Files
hermes-agent/pyproject.toml
T
Teknium 2a7308b7c4 fix(update): quarantine hermes.exe vs concurrent Windows instance (#26670) (#26677)
* fix(update): detect concurrent hermes.exe on Windows; retry + restart-defer quarantine

Closes #26670.

When 'hermes update' runs on Windows with another hermes.exe alive (most
commonly the Hermes Desktop Electron app's spawned backend) _quarantine_running_hermes_exe()
fails to rename the venv shim with [WinError 32]. uv pip install -e .
then exits 2, the git-pull fast path is silently abandoned, and the ZIP
fallback runs (and fails the same way) before eventually succeeding.

This change implements three of the five proposed fixes from the issue:

1. Concurrent-instance detection (preferred fix). _detect_concurrent_hermes_instances()
   uses psutil to enumerate processes whose .exe is one of our venv shims
   (hermes.exe / hermes-gateway.exe), excluding the caller's PID. When any
   match exists, cmd_update prints an actionable message naming the
   blocking PIDs and exits 2 BEFORE any destructive work. New --force flag
   bypasses the gate.

2. Retry + restart-deferred fallback. _quarantine_running_hermes_exe()
   now retries the rename up to 4 times with 100/250/500/1000 ms backoff
   (covers the transient AV-scanner-handle case). If all retries fail,
   it schedules the replacement via MoveFileExW with the OS deferred-rename
   flag so the new shim can land at the original path and the update
   completes; the old image is fully unloaded after the user's next
   system restart.

3. Actionable warning text. The old 'Could not quarantine: [WinError 32]'
   warning is replaced with one that names the likely culprits (Hermes
   Desktop, REPLs, gateway, AV) and points to the new --force flag.

Tests:
- 13 new tests in tests/hermes_cli/test_update_concurrent_quarantine.py
  covering: psutil-based enumeration, self-pid exclusion, case-insensitive
  matching of .EXE, no-psutil graceful degradation, off-Windows no-op,
  helpful warning formatting, retry-then-succeed, restart-deferred fallback,
  cmd_update abort + exit code 2, and --force bypass.
- New autouse fixture in tests/hermes_cli/conftest.py defaults
  _detect_concurrent_hermes_instances to [] so the rest of the suite
  isn't tripped by the developer's own running hermes.exe. Opt-out marker
  'real_concurrent_gate' registered in pyproject.toml.
- Updating docs page (website/docs/getting-started/updating.md) gains a
  short section explaining the new Windows error and remediation.

* chore: refresh uv.lock to match pyproject.toml exact pins

aiohttp 3.13.4 -> 3.13.3 (matches pyproject pin: aiohttp==3.13.3)
anthropic 0.87.0 -> 0.86.0 (matches pyproject pin: anthropic==0.86.0)
hermes-agent 0.13.0 -> 0.14.0 (matches pyproject version)

CI's uv lock --check was failing on the merged state because main
drifted: pyproject.toml uses exact == pins for those two deps and the
hermes-agent version was bumped to 0.14.0 but the lockfile still had
0.13.0.
2026-05-19 11:10:51 -07:00

260 lines
11 KiB
TOML

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "hermes-agent"
version = "0.14.0"
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
readme = "README.md"
requires-python = ">=3.11"
authors = [{ name = "Nous Research" }]
license = { text = "MIT" }
dependencies = [
# Core — every direct dep is exact-pinned to ==X.Y.Z (no ranges).
# Rationale: ranges allow PyPI to ship a fresh version of a transitive
# at any time without a code review on our side. Exact pins mean the
# only way a new package version reaches a user is via an intentional
# update on our end (bump the pin in this file, regenerate uv.lock).
# This was tightened on 2026-05-12 in response to the Mini Shai-Hulud
# worm hitting mistralai 2.4.6 on PyPI; if that release had been
# captured by `mistralai>=2.3.0,<3` rather than an exact pin, every
# install in the hours before the quarantine would have pulled it.
#
# When updating: bump the version below AND regenerate uv.lock with
# `uv lock` so the transitive resolution stays consistent. Don't
# introduce ranges back without a written justification.
#
# Scope rule: only packages used by EVERY hermes session belong here.
# Anything that's provider-specific (`anthropic`, `firecrawl-py`,
# `exa-py`, `fal-client`, `edge-tts`, `parallel-web`) belongs in an
# extra and gets lazy-installed via `tools/lazy_deps.py` when the
# user picks that backend. Smaller `dependencies` = smaller blast
# radius for the next supply-chain attack.
"openai==2.24.0",
"python-dotenv==1.2.2",
"fire==0.7.1",
"httpx[socks]==0.28.1",
"rich==14.3.3",
"tenacity==9.1.4",
"pyyaml==6.0.3",
"ruamel.yaml==0.18.17",
"requests==2.33.0", # CVE-2026-25645
"jinja2==3.1.6",
"pydantic==2.12.5",
# Interactive CLI (prompt_toolkit is used directly by cli.py)
"prompt_toolkit==3.0.52",
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
"croniter==6.0.0",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.12.1", # CVE-2026-32597
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
# out of the box. ``tzdata`` ships the Olson database as a data package
# Python resolves automatically. No-op on Linux/macOS (which have
# /usr/share/zoneinfo). Credits: PR #13182 (@sprmn24).
"tzdata==2025.3; sys_platform == 'win32'",
# Cross-platform process / PID management. `psutil` is the canonical
# answer for "is this PID alive" and process-tree walking across Linux,
# macOS and Windows. It replaces POSIX-only idioms like `os.kill(pid, 0)`
# (which is a silent killer on Windows — see CONTRIBUTING.md) and
# `os.killpg` (which doesn't exist on Windows).
"psutil==7.2.2",
]
[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.86.0"]
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
firecrawl = ["firecrawl-py==4.17.0"]
parallel-web = ["parallel-web==0.4.2"]
# Image generation backends
fal = ["fal-client==0.13.1"]
# Edge TTS — default TTS provider but still optional (users can pick
# ElevenLabs / OpenAI / MiniMax instead).
edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
vercel = ["vercel==0.5.7"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-xdist==3.8.0", "pytest-split==0.11.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
cli = ["simple-term-menu==1.6.6"]
tts-premium = ["elevenlabs==1.59.0"]
voice = [
# Local STT pulls in wheel-only transitive deps (ctranslate2, onnxruntime),
# so keep it out of the base install for source-build packagers like Homebrew.
"faster-whisper==1.2.1",
"sounddevice==0.5.5",
"numpy==2.4.3",
]
pty = [
"ptyprocess==0.7.0; sys_platform != 'win32'",
"pywinpty==2.0.15; sys_platform == 'win32'",
]
honcho = ["honcho-ai==2.0.1"]
mcp = ["mcp==1.26.0"]
homeassistant = ["aiohttp==3.13.3"]
sms = ["aiohttp==3.13.3"]
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0"]
acp = ["agent-client-protocol==0.9.0"]
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of
# `mistralai` returns 404 on PyPI right now, so any pin we'd write is
# unresolvable, which breaks `uv lock --check` in CI.
#
# To restore once PyPI un-quarantines:
# 1. Verify the new release is clean (read the changelog, check Socket
# advisory page, confirm no malicious code review findings).
# 2. Add back: mistral = ["mistralai==<verified-version>"]
# 3. Re-enable Mistral in:
# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"])
# - hermes_cli/tools_config.py (un-hide from provider picker)
# - hermes_cli/web_server.py (re-add to dashboard STT options)
# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs)
# 4. Run `uv lock` to regenerate transitives.
# 5. Optionally re-add to [all] only after a few days of clean operation.
bedrock = ["boto3==1.42.89"]
azure-identity = ["azure-identity==1.25.3"]
termux = [
# Baseline Android / Termux path for reliable fresh installs.
"python-telegram-bot[webhooks]==22.6",
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[pty]",
"hermes-agent[mcp]",
"hermes-agent[honcho]",
"hermes-agent[acp]",
]
termux-all = [
# Best-effort "install all" profile for Termux. Same policy as [all]:
# only includes extras that aren't covered by `tools/lazy_deps.py`.
# Backends like telegram/slack/dingtalk/feishu/honcho lazy-install at
# first use, so they're no longer eager-installed here.
"hermes-agent[termux]",
"hermes-agent[google]",
"hermes-agent[homeassistant]",
"hermes-agent[sms]",
"hermes-agent[web]",
]
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
google = [
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
# Sheets, Docs). Declared here so packagers (Nix, Homebrew) ship them with
# the [all] extra and users don't hit runtime `pip install` paths that fail
# in environments without pip (e.g. Nix-managed Python).
"google-api-python-client==2.194.0",
"google-auth-oauthlib==1.3.1",
"google-auth-httplib2==0.3.1",
]
youtube = [
# Required by skills/media/youtube-content and
# optional-skills/productivity/memento-flashcards (youtube_quiz.py).
# Without this declaration uv sync omits the package and both skills fail
# at first invocation with ModuleNotFoundError (issue #22243).
"youtube-transcript-api==1.2.4",
]
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
# session can use, things needed before the agent loop is alive
# (terminal/CLI), and skill deps that packagers (Nix, AUR, Homebrew)
# need in the wheel. Anything an opt-in backend (provider, search,
# TTS, image, memory, messaging platform, terminal sandbox) needs
# MUST live exclusively in `LAZY_DEPS` and resolve at first use —
# otherwise one quarantined PyPI release breaks every fresh install.
#
# Removed from [all] on 2026-05-12 (covered by lazy-install):
# anthropic, exa, firecrawl, parallel-web, fal, edge-tts,
# modal, daytona, vercel, messaging (telegram/discord/slack),
# matrix, slack, honcho, voice (faster-whisper),
# dingtalk, feishu, bedrock, tts-premium (elevenlabs)
#
# Why: the matrix extra in particular pulls `mautrix[encryption]`
# which depends on `python-olm`. python-olm has Linux-only wheels and
# no native build path on Windows or modern macOS. With matrix in
# [all], `uv sync --locked` on Windows tried to build it from sdist
# and failed on `make`. Lazy-install routes that build to first use,
# where the user is expected to have a toolchain available.
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[dev]",
"hermes-agent[pty]",
"hermes-agent[mcp]",
"hermes-agent[homeassistant]",
"hermes-agent[sms]",
"hermes-agent[acp]",
"hermes-agent[google]",
"hermes-agent[web]",
"hermes-agent[youtube]",
]
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
gateway = ["assets/**/*"]
plugins = [
"*/dashboard/manifest.json",
"*/dashboard/dist/*",
"*/dashboard/dist/**/*",
]
[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
addopts = "-m 'not integration' -n auto"
[tool.ty.environment]
python-version = "3.13"
[tool.ty.rules]
unknown-argument = "warn"
redundant-cast = "ignore"
[tool.ruff]
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
[tool.ruff.lint]
# All other lints are intentionally disabled (see comment history on this
# file) while we wrangle typechecks — but PLW1514 is too load-bearing to
# keep off. Bare open()/read_text()/write_text() in text mode defaults to
# the system locale encoding on Windows (cp1252 on US-locale installs),
# which silently corrupts any non-ASCII file content. We had three
# separate Windows sandbox regressions in one debug session before
# adding the explicit encoding. This rule keeps new code honest.
select = ["PLW1514"]
[tool.ruff.lint.per-file-ignores]
# Tests can intentionally exercise locale-encoding edge cases.
"tests/**" = ["PLW1514"]
# Skills and plugins are partially user-authored — their own conventions.
"skills/**" = ["PLW1514"]
"optional-skills/**" = ["PLW1514"]
"plugins/**" = ["PLW1514"]