startSocket() awaits useMultiFileAuthState() and fetchLatestBaileysVersion()
before it creates a socket or registers event handlers, and the close handler
re-entered it via a bare setTimeout(startSocket, ...). That leaves two
unrecoverable failure modes on a reconnect:
- a rejection is an unhandled promise rejection (fatal on modern Node)
- a hang leaves the bridge permanently disconnected with nothing left to
retry, while its HTTP server keeps answering 503 to the gateway
The second mode was observed in the field: fetchLatestBaileysVersion() is a
plain fetch to raw.githubusercontent.com with no AbortSignal, and after a
stream:error 503 disconnect the bridge logged 'Reconnecting in 3s...' once
and then sat silent and disconnected for 27+ hours until manually restarted.
Fix, as two pure helpers in bridge_helpers.js (keeping bridge.js side-effect
free to test):
- createReconnectScheduler(): every (re)connect entry point now catches a
failed startSocket() and reschedules it instead of dying or going silent
- createVersionResolver(): bounds the version fetch with a 15s timeout and
falls back to the last known-good version (or the Baileys default before
first success) instead of pending forever
The check-attribution CI gate kept bouncing salvage PRs because mapping
contributor emails was a manual, easy-to-forget step (bare
<login>@users.noreply.github.com emails don't auto-resolve like the
<id>+<login> form).
- scripts/audit_pr_attribution.py: mirrors the CI gate's logic exactly
(merge-base scan, same skip rules). Report mode for pre-push checks;
--fix auto-resolves via the bare-noreply local part (verified against
the GitHub users API) or GitHub email search, then writes
contributors/emails/<email> files via add_contributor.py. Prints a
confirm-the-human warning on bare-noreply resolution since the local
part is user-controlled (the bryan->hydraxman case).
- contributor-check.yml: failure output + review_status how_to_fix now
lead with the one-command fix instead of hand-editing instructions
(also drops the stale 'edit AUTHOR_MAP' guidance — AUTHOR_MAP is
frozen).
`hermes desktop` still failed with EBADENGINE demanding Node >=26 after
#76562, on a machine whose `apps/desktop/package.json` already said
`^20.19.0 || >=22.12.0`. #76562 fixed the manifest but not its mirror in
`package-lock.json`, and `npm ci` reads engines from the lockfile:
package.json apps/desktop -> {'node': '^20.19.0 || >=22.12.0'}
package-lock apps/desktop -> {'node': '>=26.0.0'} <- what gated
Chasing that exposed a second, pre-existing problem: the floor #76562
declared was too generous. Running the real `npm ci` against the whole
workspace on Node 22.21.1 fails on a transitive dependency —
npm error notsup Not compatible with your version of node/npm:
react-router@8.3.0
npm error notsup Required: {"node":">=22.22.0"}
react-router 8.3.0 (a direct dependency of both `apps/desktop` and `web`)
declares `>=22.22.0`, which is tighter than Vite's `^20.19 || >=22.12` and
excludes all of Node 20. So `>=20.0.0` promised support the tree cannot
deliver: an install on Node 20 or early 22 passed the installer's gate and
then died inside `npm ci` on someone else's package.
All four engine declarations now state the floor the dependency tree
actually has, `>=22.22.0`: root `package.json`, `apps/desktop/package.json`,
and both of their `package-lock.json` mirrors. The installer gates move with
them (`node_satisfies_build` in install.sh, `Test-NodeVersionOk` in
install.ps1) so a too-old system Node is replaced with the managed one
*before* npm runs, and the failure a user does see names hermes-agent rather
than a transitive package. NODE_VERSION stays 22 — latest-v22.x is 22.23.2,
comfortably above the floor.
The invariant test gains the case that would have caught the mirror drift on
its own: the desktop assertion now pins the tightest floor a dependency
actually declares, and the managed-runtime check compares majors, since
install.sh fetches latest-v{major}.x rather than {major}.0.0.
Verified with real `npm ci --dry-run` over the full workspace:
- node 22.23.2 (what install.sh provisions) -> 1258 packages
- node 26.5.1 -> 1189 packages
- node 22.21.1 (below the floor) -> EBADENGINE naming
hermes-agent, i.e. our own manifest, not react-router
Fresh installs and `hermes update` both fail at the first `npm ci`:
npm error code EBADENGINE
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
npm error notsup Actual: {"node":"v24.15.0","npm":"11.12.1"}
`.npmrc` sets engine-strict=true, so `engines` is a hard gate on every
install. The floor was raised to npm >=12 — but no Node release bundles
npm 12: Node 26 ships 11.17.0, 24 ships 11.16.0, 22 ships 10.9.8. The
requirement is unsatisfiable by any stock toolchain, so the installer
provisions a Node and is immediately unable to install with it.
engines.npm becomes `<11.10.0 || >=11.17.0`. That still excludes the band
the strictness was actually for: npm 11.10-11.16 honor `min-release-age`
but ignore `min-release-age-exclude`, both set in .npmrc, so they apply the
14-day gate to packages we exempted. Verified rather than assumed — npm
11.12.1 fails `ETARGET ... vite@8.2.0 with a date before 7/18/2026` while
11.17.0 installs it.
engines.node returns to >=20.0.0 and the toolchain floor to Node 22.
Nothing in the tree needs 26: Vite 8.2.0 declares `^20.19.0 || >=22.12.0`
and Electron 40 declares `>=12.20.55`. Requiring 26 force-migrated every
working install for no dependency reason. apps/desktop drops to Vite's own
floor for the same reason; the desktop bundle builds clean on Node 22.
install.sh gained a second gate: a system Node was accepted on version
alone, so a machine with Node 24 + its bundled npm 11.16.0 (the bad band)
passed the check and then failed `npm ci`. npm_supports_npmrc() now rejects
that band and installs the managed Node instead.
CI, Docker and nix are hermetic and keep pinning Node 26 / npm 12 — they
provision their own toolchain, and both satisfy the relaxed range.
tests/test_engines_satisfiable.py encodes the invariants that would have
caught this: the npm floor must be met by an npm some shipping Node
bundles, the node floor by the runtime install.sh provisions, the desktop
floor by its own build toolchain, and the lockfile mirror must match.
Restoring the broken values fails 5 of them with the reason stated.
Verified end-to-end (real downloads, temp HERMES_HOME):
- fresh install: managed node v22.23.2 / npm 10.9.8 -> npm ci, 209 packages
- existing managed tree (v22.22.3 / npm 10.9.8) -> npm ci, 209 packages
- node 26.5.1 + bundled npm 11.17.0 -> npm ci, 208 packages
- system npm 11.12.1 (the reported case) -> EBADENGINE, recovery provisions
a managed tree and retries green
- apps/desktop `npm run build` on Node 22 -> dist built, assert passes
_nb_ensure_bundled_npm_range ran only at the tail of
_nb_install_bundled_node, so it fired just after a tarball was unpacked.
ensure_node's reuse rung returns before reaching it, leaving an existing
managed tree on whatever npm its Node major bundled.
That strands a real install: the upgrade is best-effort (`|| true`), so
one offline run leaves an at-target Node 26 tree carrying npm 11.17.0 —
below the root package.json's `engines.npm` floor of >=12, fatal under
.npmrc's engine-strict. Heal does not cover it either; the tree is at the
target major and every binary passes --version, so
_nb_managed_node_needs_heal correctly reports it healthy. Re-running the
installer, the documented recovery, never repaired it.
install.ps1 already had this right: Update-ManagedNpm is called from both
branches that yield a managed tree, including the reuse path. This is the
POSIX side of that same call site.
Reproduced on a seeded node-26.5.1/npm-11.17.0 tree: before, ensure_node
left npm at 11.17.0 and `npm ci` died with EBADENGINE; after, it upgrades
to 12.0.2 and `npm ci` installs 208 packages. An already-in-range tree
costs one --version probe (~0.13s), and the system-node path is unchanged.
Co-authored-by: ethernet8023 <arilotter@gmail.com>
Follow-up to 6fdc64efc, which fixed only the POSIX bootstrap. install.ps1
unpacks the same nodejs.org build, so Windows had the same EBADENGINE:
Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's
`engines.npm` floor of >=12, and .npmrc's engine-strict=true makes that
fatal at the first `npm ci`.
Update-ManagedNpm mirrors _nb_ensure_bundled_npm_range rung for rung —
temp cwd so the checkout's .npmrc cannot gate the upgrade meant to
satisfy it, npm_config_min_release_age=0, and an explicit --prefix at the
managed tree. EAP is relaxed around the npm call for the same reason
Install-Uv does it: npm's stderr would otherwise wrap as ErrorRecords and
short-circuit before $LASTEXITCODE is read. Env vars and location are
restored in a finally.
Called from both branches that yield a managed tree: the fresh portable
unpack, and the reuse-an-existing-tree path, where an older install still
has its original major's npm sitting there. The in-range check makes the
second a one-probe no-op on reruns.
The range comes from Get-NpmRange, which prefers the checkout's
package.json but falls back to a $NpmRange constant — unlike the POSIX
side, Test-Node runs before the repo is cloned, so there is usually no
manifest on disk yet (and none at all when install.ps1 is piped from the
web). The manifest read means a drifted constant self-corrects on any run
against an existing checkout.
Not executed locally: no pwsh on this machine, and the repo runs no
PowerShell in CI.
The bundled-Node bootstrap unpacked the nodejs.org tarball and stopped.
Node 26.5.1 bundles npm 11.17.0, one minor below the root package.json's
own `engines.npm` floor of >=12 — and .npmrc sets `engine-strict=true`,
so that is fatal rather than a warning:
npm error code EBADENGINE
npm error notsup Required: {"node":">=26.0.0","npm":">=12.0.0"}
npm error notsup Actual: {"node":"v26.5.1","npm":"11.17.0"}
A brand-new install died at the first `npm ci` with "Desktop workspace
npm install failed". CI never saw it because the workflows run an
explicit `npm i -g npm@12`; the Python update path recovers through
hermes_cli/npm_engine.py, but the installer path had no such rung.
_nb_ensure_bundled_npm_range() now upgrades the managed tree's npm into
range right after the tarball lands, mirroring upgrade_managed_npm():
- temp cwd, so the checkout's own .npmrc (engine-strict,
min-release-age) does not gate the upgrade meant to satisfy it;
- npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
- explicit --prefix at the managed tree, because
_nb_configure_npm_prefix writes prefix=~/.local into its etc/npmrc
and a bare `npm i -g` would install a second npm elsewhere while the
managed tree stayed stale.
The range is read out of package.json rather than duplicated, so the two
cannot drift, with HERMES_NPM_TARGET_RANGE as an override and a >=12.0.0
fallback for a stripped install tree. An already-in-range npm skips the
network round-trip. Best-effort: a failed upgrade warns with the manual
command and keeps the working Node, since npm_engine.py still covers the
EBADENGINE that follows.
Verified against a real tree provisioned by this bootstrap: node v26.5.1
/ npm 12.0.2, bin/npm and bin/npx still relative-symlinked into the
upgraded lib/node_modules/npm, the ~/.local/bin links resolving to 12.0.2
through the tree, and no stray second npm under ~/.local/lib.
Existing users who only ever launch Hermes (never re-run an installer)
kept their managed Node 22 tree forever: the heal path only fired for
*broken* trees, and a healthy 22 passes the --version probe. Now
"outdated" heals the same way "broken" does, on both sides of the mirror:
- hermes_constants.py: find_hermes_node_executable() checks
_managed_node_tree_outdated() (managed node major <
_HERMES_NODE_TARGET_MAJOR) and routes through the existing
once-per-process heal_hermes_managed_node(), which redownloads
latest-v26.x. When the heal fails (offline, download error) the
outdated-but-runnable tree is still returned — old Node beats no Node.
- scripts/lib/node-bootstrap.sh: _nb_managed_node_needs_heal() gains the
matching _nb_managed_node_outdated() rung, so heal_managed_node agrees
with the Python side.
This is the same shape as the managed-uv flow: resolve the managed
runtime, notice it can't satisfy the requirement, provision the right one
in place, fall back gracefully.
Tests (tests/test_hermes_constants.py): outdated tree triggers heal and
returns the upgraded binary; failed heal still serves the old tree; an
at-target tree never heals (heal stub raises).
Hermes now pins its toolchain to Node 26 everywhere. Every path that
installs, accepts, heals, or upgrades a Node runtime moves from the old
22-default / `^20.19 || >=22.12` floor to a single rule: Node >=26.
Installers:
- scripts/install.sh — NODE_VERSION=26; node_satisfies_build() collapses
the two-branch Vite floor to `major >= 26`; user-facing messages updated.
- scripts/install.ps1 — $NodeVersion=26; Test-NodeVersionOk likewise;
winget fallback switches OpenJS.NodeJS.LTS -> OpenJS.NodeJS (26 is
Current, not LTS — the LTS manifest would reinstall a too-old Node).
- Dockerfile — node_source stage node:22-bookworm-slim -> node:26 (digest
pinned, amd64 sha256:9e6f...bf73).
- nix/ was already on nodejs_26 (lib.nix, npm-12-0-2.nix); the checks.nix
wrapper check ratchets from `>= 20` to `>= 26`.
Heal/upgrade paths:
- scripts/lib/node-bootstrap.sh — HERMES_NODE_TARGET_MAJOR default 22->26
and HERMES_NODE_MIN_VERSION default 20->26, so heal_managed_node,
_nb_install_bundled_node, and the fnm/proto/nvm/brew rungs all target 26
and stop accepting an on-PATH Node below it. Both remain env-overridable.
- hermes_constants.py — _HERMES_NODE_TARGET_MAJOR fallback 22->26, which
drives the Windows heal path's latest-v26.x download.
Version gates:
- package.json engines.node >=20 -> >=26; apps/desktop engines
`^20.19.0 || >=22.12.0` -> `>=26.0.0`.
- CI setup-node: all five workflows 22 -> 26.
- Docs describing Hermes's own toolchain updated (windows-native, docker,
acp, nix-setup, contributing). Skill docs describing third-party tools'
own requirements are untouched.
Termux still installs via `pkg install nodejs` best-effort (nodejs.org
ships no Android tarballs); that path was never version-gated.
Verified: bash -n on both shell scripts, PowerShell AST parse of
install.ps1, latest-v26.x index resolves (node-v26.5.1), and the install
test suite — 18 tests across the 5 install/runtime test files — passes.
Hermes installs runtimes for itself — `uv` at `$HERMES_HOME/bin/uv`, Node
at `$HERMES_HOME/node` — and neither directory is on an arbitrary
process's PATH. Every `shutil.which("node"/"npm"/"npx"/"uv")` in Hermes's
own code therefore has two failure modes: the managed runtime is invisible,
so the caller reports "not installed" or degrades to a slower tier on a
machine that has exactly what it needed; and when a system copy also
exists, the one Hermes does not own wins.
Routed the Hermes-owned call sites through managed-aware resolvers:
- `agent/lsp/install.py`, `hermes_cli/dep_ensure.py`, `hermes_cli/main.py`
(`_make_tui_argv`), `hermes_cli/tools_config.py` (`_run_post_setup`) now
use `find_node_executable()`.
- `hermes_cli/tools_config.py::_pip_install` and `hermes_cli/setup.py`'s
vercel install use `ensure_uv()` (installing uv is in scope during setup,
and the Windows installer's `uv venv` does not seed pip, so the fallback
tier is "No module named pip"). `tools/lazy_deps.py` uses `resolve_uv()`
— a lookup, not a bootstrap, because it runs mid-turn for an optional
dependency and downloading a runtime as a side effect exceeds what the
caller asked for.
- `hermes_cli/gateway.py`: extracted `_append_node_dir_for_service()`,
shared by the systemd unit and launchd plist generators, which appends
the managed dirs before the PATH-resolved one. A service definition is
written once and survives reboots, so resolving a system Node that
happens to lead the installing shell's PATH bakes the wrong interpreter
in permanently. Managed dirs are profile-scoped, so each profile's unit
still names its own Node; the existing symlink-parent rule (don't
`.resolve()`) is preserved verbatim.
- `tools/environments/local.py`: the terminal tool's subshell PATH gains
the managed dirs, appended alongside the sane entries rather than
prepended — a tool the user deliberately put on their own PATH still
wins, and the managed one only fills a gap. This is also what makes the
bare `which("uv")` in `tools/env_probe.py` correct: that probe reports
the environment the *model* sees, and the model can only run what is on
that subshell's PATH.
`scripts/install.ps1`: the persisted User PATH update becomes
`Set-ManagedNodeFirstOnUserPath`, a move-to-front rather than an
add-if-missing. Installs made by an older install.ps1 already have the
managed dir in User PATH — at the tail, behind a system Node — and an
add-if-missing check sees it present and leaves that ordering in place
forever, so the users the bug hurt would never be repaired. Unrelated
entries keep their relative order (empty segments included; a trailing
`;` is legal and the installer's other PATH code preserves them),
duplicates collapse, and it writes only when the string actually changes.
Tests:
- `tests/test_managed_runtime_resolution.py` — AST guard that fails any
new bare `which()` for a managed runtime, with a short justified
allow-list and a companion test that fails when an allow-list entry goes
stale. Reading source is banned by AGENTS.md and this is the documented
exception: the property is "no call site anywhere spells it this way",
which no runtime seam can observe.
- `scripts/ci/test_install_ps1_path_migration.ps1` — behavioral, not a
source regex: it lifts the real `Set-ManagedNodeFirstOnUserPath` out of
install.ps1's AST and rewrites only the two registry calls into an
in-memory store, so the shipped split/dedupe/prepend/change-detection
logic executes for real. Not in the default lane (Linux runners have no
PowerShell host); runs under `pwsh`. 13/13 assertions pass.
Two paths let a pre-existing system Node win over the Hermes-managed one.
The desktop backend spawn built its managed-Node PATH entry as
`<home>/node/bin` only. That is the POSIX layout install.sh produces;
install.ps1 unpacks portable Node straight into `%LOCALAPPDATA%\hermes\node`
with node.exe at the root and no `bin\`. On Windows the entry therefore
pointed at a directory that does not exist, and the backend fell through to
whatever Node was already on PATH.
main.ts already had the correct platform-ordered list, behind a "keep this
in sync with iter_hermes_node_dirs()" comment on a second copy of the rule.
The two copies had drifted. Export the ordering from backend-env.ts and have
main.ts consume it so there is one source of truth on the Node side (the
Electron main process cannot import hermes_constants.py, so a mirror is
unavoidable — but one mirror, not two).
install.ps1 appended the node dir to the persisted User PATH instead of
prepending it. The session PATH was already prepended correctly, so this only
bit later processes: any shell opened after install, and a standalone
hermes-setup.exe run that inherits User PATH rather than a curated env, both
resolved a system Node ahead of the bundled one.
Not a bug, for the record: update.rs's prepend list omits the same Windows
root, but it inherits PATH from the desktop, which supplies the correct
entries — so it is redundant rather than broken, and no installer rebuild is
needed for this fix.
Tests: managed dirs lead with the platform-native layout while always
offering both shapes, empty without a home, and every managed dir outranks
the inherited PATH on darwin and win32. The three existing tests that pinned
`entries[1]` by index asserted the old single-dir shape and now assert the
relationship instead.
install.ps1 has no behavioral test here: CI has no PowerShell host, and
AGENTS.md bans source-reading tests (the neighbouring
test_install_ps1_node_path_for_npm.py predates that rule).
The npm 12 requirement (f88ed6c717) strands every system-Node install:
no shipping Node bundles npm >=12, engine-strict makes EBADENGINE fatal,
and the recovery in npm_engine.py refuses to touch a system npm — so
'hermes update' leaves the install in a mixed state (updated code, stale
Node deps, no TUI/web/desktop rebuild) with only a manual-fix hint.
Instead of modifying the user's toolchain (still never done), the
EBADENGINE recovery now provisions Hermes' own managed Node tree under
$HERMES_HOME/node — the same pinned-nodejs.org path install.sh and
install.ps1 use — upgrades THAT npm into the required range, and hands
the caller the managed npm for its single retry.
- hermes_constants.bootstrap_hermes_managed_node(): cross-platform
provisioning (POSIX via node-bootstrap.sh _nb_install_bundled_node,
Windows via the existing portable-zip download); reuses a healthy tree.
- node-bootstrap.sh: HERMES_NODE_SKIP_LINKS=1 skips the ~/.local/bin
node/npm/npx symlinks so the private tree never shadows the user's
own toolchain on PATH.
- maybe_repair_npm_engine() now returns the npm path to retry with
(managed-in-place upgrade or freshly provisioned runtime); both call
sites retry with the returned path and put the managed tree first on
PATH so npm lifecycle scripts resolve the managed node.
- Node-only mismatches on a foreign npm are now also recoverable (the
managed tree ships a supported Node); on a managed npm they still
correctly decline.
E2E (real download, temp HERMES_HOME): provisioned node v22.23.2,
upgraded bundled npm 10.9.4 -> 12.0.2, system npm byte-identical after,
no ~/.local/bin links re-pointed, healthy-tree reuse in 0.05s.
After the test-suite prune, the Python slices (~2.3m each) are no longer
CI's critical path — Desktop E2E (5.2m, the longest job) and the Docker
build are, and both run on every python-lane PR even when the diff never
leaves tests/. Neither consumes the test suite: Playwright drives the
built app + hermes serve backend, and the image copies installed code.
New python_prod lane = python minus tests-only diffs. e2e-desktop and
docker gate on it; every pytest/lint lane keeps gating on python.
Fail-open contract preserved: .github/ changes and empty diffs set
python_prod=true, and runner infrastructure (scripts/run_tests.sh,
run_tests_parallel.py) is deliberately NOT tests-only since a bad
runner edit can mask real failures.
Replay over the last 231 main commits: 39 (17%) would skip both jobs,
cutting their critical path from ~8m to ~3m. E2E-verified through the
real script entrypoint (tests-only/prod/mixed/fail-open) + 83 tests/ci
green.
Setting prompt_caching.cache_ttl to a falsy value (false, null, off,
disabled, no, none) now fully disables prompt caching instead of
being silently ignored.
The disable propagates through anthropic_prompt_cache_policy() (early
return when _cache_disabled flag is set) and restore_primary_runtime()
(override after snapshot restore), so it survives /model switches and
fallback re-derivation — the gap that caused #56105 to be reverted in
#56126.
Salvage of #33555 by @BB-light, with model-switch/fallback survival
gap fixed on top.
Co-authored-by: BB-light <BB-light@users.noreply.github.com>
setup_path() only wrote a 'hermes' launcher into the command-link dir,
even though pyproject.toml declares three [project.scripts]:
hermes, hermes-agent (run_agent:main), hermes-acp (acp_adapter.entry:main).
Fresh venv installs (the common case on macOS/Linux) leave
~/.local/bin/{hermes-agent,hermes-acp} empty, so external tools
expecting 'hermes-acp' as a standalone command (documented as
first-tier supported in website/docs/user-guide/features/acp.md)
fail to find it after a fully successful install.
Loop over the three console-script names, writing a shim per entry
that exec's the venv interpreter with the right checked-in
entrypoint. --no-venv keeps the old single-shim behaviour since
it does not manage the venv and only 'hermes' is guaranteed on PATH.
Fixes#74819
Two fixes for the Skills Hub "View source" links on ClawHub skills:
1. Source URL generation was missing the required {owner} segment —
https://clawhub.ai/skills/{slug} → 404. Correct format is
https://clawhub.ai/{owner}/skills/{slug}. When the owner handle is
unavailable, source_url is now "" (card omits the button) instead of
emitting a broken link.
2. _fetch_owner_handle() previously delegated to _get_json() which
returned None on any non-200 response with no retry. Under HTTP 429
rate-limiting the "50 consecutive failures" safety rail in
enrich_owners() fired immediately — the documented claim "Respects
HTTP 429 rate-limit responses with exponential backoff" was not
actually implemented. Now has its own retry loop: 3 attempts, honours
Retry-After on 429, exponential backoff on 5xx/transport errors, no
retry on 4xx.
Changes:
- tools/skills_hub.py: _coerce_skill_payload carries owner from top-level
response; inspect() captures owner from detail API; _fetch_owner_handle()
added with bounded retry/backoff; enrich_owners() batch method with
safety rails (30 workers, early termination at 50 consecutive failures).
- website/scripts/extract-skills.py: _source_url() reads extra["owner"]
for ClawHub.
- scripts/build_skills_index.py: batch enrichment step after crawling.
- tests: 35 URL/enrichment tests + 7 retry tests (42 total).
Signed-off-by: dongjiang <dongjiang1989@126.com>
Tokens saved is the wrong headline for this feature. Micro-compaction is
not an efficiency optimisation — the same summarization work happens either
way. What it buys is (a) that work amortized across turns instead of one
stall, and (b) a window kept low enough that a session runs much further
before needing a hard compaction at all.
Neither shows up in "net tokens saved". A session can save nothing on paper
and still be a clear win on both counts.
So the telemetry now carries occupancy: tokens_after as a share of the
compaction threshold, plus the threshold and resolved window it was
computed from. That is the number that says whether a session has headroom
left. The report leads with it, and cross-references the batch
`compression_attempt` lines already in the log so it can show how often the
long pause actually fired — ideally never.
Occupancy is read from the cached threshold only. The public
`threshold_tokens` property resolves lazily and can issue a synchronous
/models probe (#32221); telemetry must never be the thing that blocks a
turn, so an unresolved window reports null. In practice a pass has already
resolved it via the tail calculation, so the field is populated. A test
pins the no-forcing behaviour directly against the emitter.
The report is pure ASCII: `scripts/check_subprocess_stdin.py` currently
dies on a cp1252 console before printing its results, and a diagnostic tool
that crashes on the platform it is diagnosing is worse than no tool.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing log line reports message counts, which is the least
informative number available here: absorbing one tool-heavy exchange can
drop hundreds of tokens while moving the count by one. There was no way to
answer "is this actually helping?" from a real session.
Emit one content-free JSON line per pass, in the same shape as the batch
compaction telemetry: before/after tokens, the delta, the size of the
absorbed exchange, the rolling summary size, duration, and running
per-session totals so a whole run can be read off the last line. No
transcript content rides along.
Add scripts/micro_compaction_report.py to aggregate those lines into
passes, outcome mix, net tokens saved, mean exchange size and durations,
with an optional per-session breakdown.
Measuring it immediately surfaced something worth documenting: the first
pass in a session normally *costs* tokens. The summary marker carries a
fixed ~400 tokens of scaffolding, paid on pass one against a single
absorbed exchange. From pass two the marker is replaced rather than added,
so the overhead is already paid and each exchange is close to pure saving.
Break-even is typically the second or third pass. Tests cover the
telemetry contract, the cumulative totals, and that first-pass/later-pass
shape so nobody reads a single turn and concludes it made things worse.
The estimator costs ~5 ms at 600 messages and ~20 ms at 1200, taken twice
per pass, post-turn — and only once an exchange is actually in hand, so
turns that no-op early pay nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The file-local telegram mock in test_dm_topics.py installed unconditionally
(no __file__ guard), registered a separate string-valued telegram.constants
module, and force-popped the adapter — poisoning the session for any later
telegram test in the same process (assert 'MARKDOWN_V2' in "'MarkdownV2'").
Fix at the source:
- conftest: _FakeEnumMember(str) with PTB-faithful str()==value and
repr()==<ChatType.X: 'x'>, satisfying both repr assertions and the
adapter's str(chat.type) normalization; the same object is bound to
mod.ParseMode and mod.constants.ParseMode.
- test_dm_topics.py: delete the divergent local mock installer; import the
shared conftest one.
- release.py: mailmap entry for the author.
Verified: the 5-failure cluster repro (dm_topics + slash_confirm +
approval_buttons + model_picker + network_reconnect + telegram_format in one
process) goes 83/83 green (3x); full tests/gateway single-process run drops
10 -> 5 failed, the remainder being pre-existing discord order-dep failures
out of scope here.
Salvaged from #68873. Credit to @liuhao1024 for the earliest root-cause
diagnosis of this str-enum mock class in PR #33875, two months earlier.
Fixes the telegram-mock order-dependent flake cluster.
Two Windows fixes for the canonical test runner, salvaged from #66496:
- scripts/run_tests.sh: probe the native Windows venv layout
(Scripts/activate → Scripts/python.exe) alongside bin/activate,
adapted to main's pytest-import-guarded loop with SKIPPED_VENVS
reporting. Without it a python -m venv / uv venv on Git Bash/MSYS is
never found and the runner refuses to start.
- scripts/run_tests_parallel.py: _make_stdio_glyph_safe() reconfigures
stdout/stderr to UTF-8 (errors=replace fallback) so the ✓/✗ progress
glyphs cannot crash a cp1252 console when the runner is invoked
directly (run_tests.sh's PYTHONUTF8=1 only covers the wrapped path).
No-op on UTF-8 stdio. Ships 3 OS-independent cp1252 tests plus
encoding=utf-8 in the runner-subprocess assertions.
Dropped from the original PR: the USERPROFILE/HOMEDRIVE/HOMEPATH/
SYSTEMROOT env-forwarding hunk — main's WIN_ENV loop already forwards
a superset (#67385/#70813).
Combines the Windows-hermeticity cluster (#67512 by @webtecnica, earliest;
#71112 by @Sanjays2402; #67196 by @anatolijlaptev1991-ctrl) into one fix:
- scripts/run_tests.sh: env -i forwarded only HOME, but native Windows
CPython resolves Path.home() from USERPROFILE (or HOMEDRIVE+HOMEPATH),
stdlib paths from LOCALAPPDATA/APPDATA, ssl/sockets need SYSTEMROOT,
tempfile needs TEMP/TMP — the strip broke collection tree-wide on
native Windows (issues #67385, #70813). Location vars (never
credentials) are now forwarded, each only when actually set, so
POSIX runs are byte-for-byte unchanged (probe-verified both ways).
PYTHONUTF8=1 added for legacy-codepage consoles printing the
runner's glyphs.
- tests/plugins/memory/test_hindsight_provider.py: _clean_env patched
HOME only; on Windows Path.home() ignores HOME. Now patches
Path.home directly into tmp_path (from #67196).
Not ported: #71112's guard test — it regex-reads run_tests.sh source,
which the test policy bans (never read source code in tests).
Fixes#67385. Fixes#70813.
hermes-setup.exe bakes its build-time commit into the binary
(BUILD_PIN_COMMIT) and passes it as -Commit on every install-mode run,
including the retry the desktop's "Update didn't finish" screen kicks
off. The repository stage checked that SHA out unconditionally, so an
installer built months earlier rewound a current managed checkout to its
build commit -- 9,160 commits in the reported case -- leaving ancient
source against a current venv. npm then failed on workspaces that did not
exist yet at that commit, and every later update ran against the wrong
tree.
Skip the pin when its target is already an ancestor of HEAD. Fresh clones
have no such ancestry so reproducible/CI pinning is unchanged, and
--force-commit / -ForceCommit still rolls back on purpose.
install.sh probed pypi.org and duckduckgo.com serially with
--max-time 8 each, so a fully blocked network cost 16s before the user
saw any useful guidance. The two probes are independent; running them
as background jobs and gathering verdicts caps the worst case at one
--max-time (8s) while the good path stays instant.
Verified live: reachable URLs 0.24s (both probes concurrent);
blackholed 10.255.255.x URLs 8.02s total (was 2x8s), warning text
unchanged. bash -n clean.
The Skills Hub 'Marketplace' tab showed a single useless entry: Anthropic
changed .claude-plugin/marketplace.json to bundle-shaped plugins whose
source is './', so all plugins collapsed to one identifier pointing at the
repo root, and the second marketplace repo (aiskillstore/marketplace) is
gone (404). Everything in anthropics/skills is already surfaced by the
GitHub tap as the Anthropic tab, making this source fully redundant.
Removes ClaudeMarketplaceSource and all wiring: source router, index
builder (crawl + floors + sort order + rate-limit messaging), extract
labels/install/URL mapping, hub UI tab, web server labels, CLI limits,
docs (en + zh), the legacy index-cache snapshot, and test fixtures.
Stale skills-index entries with source 'claude-marketplace' still install
fine: HermesIndexSource fetches via resolved GitHub paths generically.
install.sh duplicated the raw deep ad-hoc re-sign, so install/repair and
self-update could disagree about the app's signing identity — an update
signed with the stable identity would be clobbered back to a cdhash-only
DR by the next installer repair. Call the shared Python fixup (passing
the shell's publisher-signing decision explicitly), and branch into the
historical xattr + deep ad-hoc repair when the venv helper is missing or
fails so a broken venv never leaves the bundle unlaunchable.
Co-authored-by: cipry0200 <cipry0200@users.noreply.github.com>
Co-authored-by: natebransc <natebransc@users.noreply.github.com>
Co-authored-by: caseyanthony <caseyanthony@users.noreply.github.com>
Running the suite could take over the machine it ran on. Two real
effects, both now closed:
- **The suite spoke through the speakers.** Once any test drove the
`voice.toggle` RPC with `action="tts"`, the handler set
`HERMES_VOICE_TTS=1` in the *live process environment*, and the flag
outlived that test. Every later test that drove a turn to completion
then fed its final response text to `hermes_cli.voice.speak_text` on a
background thread - real synthesis, real playback, no API key needed
(the default `edge` provider is keyless). A developer heard the fixture
string "partial answer complete" out loud. Because the flag is set from
inside the process, `scripts/run_tests.sh`'s `env -i` never protected
against this.
`tests/conftest.py` now blanks `HERMES_VOICE`/`HERMES_VOICE_TTS` per
test, and a new autouse `_audio_playback_guard` stubs `speak_text` and
its playback binding outright, so the speakers stay shut even inside the
test that sets the flag itself. `@pytest.mark.real_audio_playback` opts
out.
- **The suite launched Chrome.** `tests/tools/test_browser_supervisor.py`
spawned a real browser on any machine with Chrome on `PATH`. Its
docstring promised a `HERMES_E2E_BROWSER=1` gate that existed nowhere in
the code. That gate is now real, and the file is marked `integration`
so the default marker filter excludes it. `scripts/run_tests.sh`
forwards `HERMES_E2E_BROWSER` so the documented manual run still works.
Miscellanea
- `tests/test_audio_playback_guard.py`: regression cover for both defences,
driving the real `voice.toggle` handler rather than a stand-in.
setup_path() wrote a `hermes` launcher to ~/.local/bin but nothing for
`hermes-acp`. That console script exists only inside the venv, which is
not on the login-shell PATH.
ACP hosts resolve the agent by command name against that PATH, so an
otherwise healthy install looks absent to them. Buzz Desktop ships a
Hermes preset that spawns `hermes-acp` and reports the runtime as
unavailable; Zed and JetBrains configs that name the bare command have
the same problem.
Write a hermes-acp launcher next to the hermes one, dispatching to the
acp subcommand. Same PYTHONPATH/PYTHONHOME clearing, and the same rm -f
before cat > so an older symlink into the venv cannot be followed and
stomp the console script (#21454). Uninstall removes both launchers.
tests/test_install_sh_acp_launcher.py drives the block out of install.sh
rather than asserting on a copy, covering the venv and non-venv branches
plus the symlink-stomp case. Reverting the install.sh change turns all
three red.
Signed-off-by: SHL0MS <SHL0MS@users.noreply.github.com>
test_install_ps1_is_pure_ascii guards against PowerShell 5.1 ANSI
codepage misdecoding (issues #66994/#67000); the Install-DesktopVoiceDeps
comment had an em-dash.
Two fixes from live testing (Teknium):
1. Desktop installs now ship the wake/voice stacks up front.
install.sh + install.ps1 desktop stages run 'uv pip install
-e .[wake,voice]' (best-effort, lazy-install remains the fallback)
before building the app, so the first ear-click arms instantly
instead of sitting through a multi-minute onnxruntime download.
CLI-only installs keep the lazy path — [all] curation unchanged.
2. The vanished ear: the STT/TTS gate made wake.status call
check_tts_requirements(), whose edge path runs _import_edge_tts →
lazy_deps.ensure — a synchronous PIP INSTALL inside a status poll.
On a venv without edge-tts that blew the desktop's 30s RPC timeout,
armWakeWord caught the error, the atom never learned enabled=true,
and the ear unmounted. _tts_ready is now a pure probe: deps missing
+ lazy installs allowed counts as ready (installs at first speak)
WITHOUT touching pip; check_tts_requirements only runs once deps
are present. Regression test asserts the probe never calls it while
deps are missing.
install.ps1 wrote the marker on Windows and the Rust installer now writes it,
but install.sh -- the path every Mac and Linux CLI install takes -- never did.
A machine set up with install.sh therefore looked uninstalled to the desktop
app, which re-ran first-run bootstrap on every launch.
Stamp the same schema-v1 payload install.ps1 writes, from both the staged
`complete` stage and monolithic main(). An unresolvable HEAD skips the marker
rather than writing one the desktop validator rejects: absent reads as a clean
"bootstrap needed", malformed reads as a confusing half-state.
The Windows-footgun check is red on current main, not just on this branch:
a2c42be93c added `encoding="utf-8"` to one `write_text` in this file and
missed the other two.
Line 190 is what the checker reports. Line 211 has the same defect but the
call is split across lines, so the single-line regex never flagged it —
fixing only the reported site would have left the same bug in the file and
re-armed it for the next reader.
Both now pass an explicit encoding, matching line 68.