Salvages the closed PR #2010 (Mibayy's EVM multi-chain skill) and folds the
existing optional-skills/blockchain/base/ skill into it, so we ship one
unified EVM skill instead of two overlapping ones.
Pulled in from base/:
- 8 missing Base-specific tokens (AERO, DEGEN, TOSHI, BRETT, WELL,
cbETH, cbBTC, wstETH, rETH) added to KNOWN_TOKENS['base'] —
base/ had 11, evm/ only had 3 (USDC/DAI/WETH).
- L1 data-fee pitfall note for rollups (Base, Arbitrum, Optimism, zkSync).
- Batch-size chunking in rpc_batch (Base RPC caps batches at 10 calls
per JSON-RPC request; adding more known tokens tripped that limit
and broke 'wallet --chain base' with a 'list index out of range'
error). Ported the chunking pattern from base/_rpc_batch_chunk.
Latent bugs found and fixed while smoke-testing the merge:
- cmd_multichain and cmd_allowance both iterated KNOWN_TOKENS[chain]
with 'for contract, (symbol, _name) in known.items()' — but the dict
shape is {symbol: contract_str}, not {addr: (sym, name)}. This raised
'too many values to unpack (expected 2)' on every non-zero balance.
Now iterates as 'for symbol, contract in known.items()'.
- Input validation: added is_valid_address / is_valid_txhash /
require_address / require_txhash helpers and wired them into
cmd_wallet, cmd_tx, cmd_token, cmd_activity, cmd_allowance,
cmd_decode, cmd_contract, cmd_multichain. Fails fast with exit 2
on malformed input instead of burning an RPC round-trip on garbage.
Documentation:
- SKILL.md now flags that this skill supersedes optional-skills/blockchain/base.
- Pitfalls expanded for ENS (single-endpoint dependency on
ensideas.com), tx decoding (single-endpoint dependency on
4byte.directory), and rollup L1 fees.
- Regenerated website/docs/user-guide/skills/optional/blockchain/
blockchain-evm.md and removed the old blockchain-base.md page;
catalog updated.
Removed:
- optional-skills/blockchain/base/SKILL.md
- optional-skills/blockchain/base/scripts/base_client.py
- website/docs/user-guide/skills/optional/blockchain/blockchain-base.md
Smoke-tested live against Base mainnet: stats, price, token, wallet
(vitalik.eth — 3.12 ETH + 13.88 USDC + 4.23 DAI + 0.06 WETH on Base)
and allowance (ethereum, 7 unlimited approvals to Uniswap/Permit2).
Original PR #2010 author: Mibayy.
Original base/ skill author: youssefea.
Previously :latest tracked the tip of main, which meant pulling :latest
got you whatever was last merged — fine for development, surprising for
users who expect :latest to mean 'the most recent stable release'.
Reshape the publish flow so the floating tags carry their conventional
meaning:
- :sha-<sha> every main commit (unchanged, immutable)
- :main tip of main (NEW; what :latest used to do)
- :<release_tag> every published release, e.g. :v1.2.3 (unchanged)
- :latest most recent release (CHANGED; release-only now)
Implementation:
- Rename the move-latest job to move-main; it still gates on push to
main, still ancestor-checks the existing :main label before
retagging, still uses cancel-in-progress: false so queued moves run
serially.
- Add a new move-latest job gated on release: published. Reads the
OCI revision label off the existing :latest and only advances if
the release commit is a strict descendant. This keeps backport
releases on older branches (e.g. patching v1.1.5 after v1.2.3 has
already shipped) from dragging :latest backwards.
- merge job exposes pushed_release_tag and release_tag outputs so
move-latest knows when to fire and what to retag from.
The old mtime-tracking staleness machinery (_tui_build_needed,
_hermes_ink_bundle_stale, _find_bundled_tui) tried to avoid rebuilding
by comparing source timestamps to dist/entry.js. This was fragile and
added ~100 lines of code. Replace with three clear paths:
1. HERMES_TUI_DIR set (prebuilt/nix): just node dist/entry.js, no build
2. --dev mode: tsx src/entry.tsx, no build, hot reload
3. Normal: always npm run build (esbuild is ~1s, correctness > caching)
Also error when HERMES_TUI_DIR is set with --dev (footgun: prebuilt
bundle has no source code to hot-reload).
Adds `pull_request` trigger to docker-publish.yml so PRs that touch
Dockerfile / docker/ / pyproject.toml / uv.lock / the workflow itself
verify the image builds cleanly before merge. Previously, Dockerfile
regressions (e.g. a stale uv.lock, a typo'd dep) would only surface
after merge when the docker-publish workflow ran on main.
Build-verify-only on PRs: the per-arch jobs run their `load: true`
build + smoke test, but the push-by-digest + artifact upload steps
remain gated on push-to-main or release. The `merge` and
`move-latest` jobs stay excluded from PRs by their existing `if:`
gates, so :latest and SHA tags are never touched from PR runs.
Concurrency: PR runs use a PR-scoped group (`docker-<pr_number>`)
with `cancel-in-progress: true` so rapid pushes to the same PR
collapse to the latest commit. Push/release runs keep
`cancel-in-progress: false` — every merge still gets its own
SHA-tagged image.
Also adds arm64 smoke tests (previously amd64-only): the image is
now built with `load: true` on arm64 too, then `docker run --help` +
`dashboard --help` smoke tests run identically on both arches. Both
smoke test blocks were extracted into a new composite action at
`.github/actions/hermes-smoke-test` to keep the two jobs DRY.
New files:
- .github/actions/hermes-smoke-test/action.yml
Modified:
- .github/workflows/docker-publish.yml
Runs `uv lock --check` on every PR and on push to main that touches
pyproject.toml, uv.lock, or this workflow itself. Exits non-zero if
the lockfile is out of sync with pyproject.toml, blocking the PR
before it can break the Docker build on main.
Rationale: the new Dockerfile layout uses `uv sync --frozen --extra all`,
which rejects stale lockfiles. Without this guard, a PR that changes
pyproject.toml dependencies but forgets to regenerate uv.lock would
merge fine and then break docker-publish on main (visible only after
~15 min of build time, producing no image).
On failure, the step adds a GitHub annotation and a workflow summary
block with the exact commands to run locally (`uv lock`,
`git add uv.lock`, `git commit`).
Verified locally that:
- Clean tree: `uv lock --check` succeeds (resolves in ~2ms, no work).
- Stale lockfile (added cowsay to pyproject.toml, not in lock): exits 1
with message 'The lockfile at `uv.lock` needs to be updated'.
Before this change, `uv pip install -e ".[all]"` ran AFTER `COPY . .`,
so every commit that changed any .py file busted the layer cache and
re-did the entire Python dep resolve + wheel download + native extension
compile (~4-5 min on cold Docker Hub cache).
Split it into two steps:
1. Before `COPY . .`: copy only pyproject.toml + uv.lock + README.md,
then `uv sync --frozen --no-install-project --all-extras`. This
layer is cached unless any of those three files change, so .py-only
commits skip the heavy work entirely.
2. After `COPY . .` (and its downstream chmod/chown step): run
`uv pip install --no-cache-dir --no-deps -e .` to create the
editable link. With --no-deps this is a ~1s op — no resolution, no
downloads, no compilation.
Combined with the per-arch runner split in the previous commit, this
should drop cache-hit build times to the sub-5-min range.
Build amd64 and arm64 natively on their own GitHub runners in
parallel, then stitch the per-arch digests into a tagged multi-arch
manifest. Replaces the previous single-runner pattern which rebuilt
arm64 from scratch on every run because QEMU emulation + unscoped GHA
cache meant no layer reuse across invocations.
Jobs:
build-amd64 — ubuntu-latest, native, runs smoke tests, pushes by
digest
build-arm64 — ubuntu-24.04-arm, native (no QEMU), pushes by digest
merge — stitches both digests into :sha-<sha> (main) or
:<release>
move-latest — unchanged ancestor-check logic, now needs: merge
Preserved:
- per-commit sha-<sha> tags on main (immutable, race-free)
- org.opencontainers.image.revision label on each per-arch image
- dashboard subcommand smoke test (#9153 guard)
- race-safe :latest advancement via move-latest
- top-level cancel-in-progress: false
Changed behavior:
- move-latest flipped to cancel-in-progress: false for
defense-in-depth.
Top-level concurrency already serializes runs for the ref, so the
old
cancel=true on move-latest was dead code. Flipping to false
prevents
any starvation mode if top-level is ever loosened.
Cache scopes separated per-arch (scope=docker-amd64 /
scope=docker-arm64)
so the two runners don't clobber each other in the gha cache backend.
Switch top-level concurrency to cancel-in-progress=false so every push
to main gets its own SHA-tagged image published — no more discarded
builds when commits land back-to-back.
Guard the :latest tag with a second job that has its own concurrency
group with cancel-in-progress=true plus a git-ancestor check against
the revision label on the current :latest. Together these guarantee
:latest only ever moves forward in history: a slower run whose commit
isn't a descendant of the current :latest refuses to clobber it, and
a newer push mid-way through the move-latest job preempts the older
one before it can retag.
- Every main push publishes nousresearch/hermes-agent:sha-<commit>
with an org.opencontainers.image.revision label embedded.
- move-latest job reads that label off :latest, runs merge-base
--is-ancestor, and only retags (via buildx imagetools create,
registry-side, no rebuild) if our commit strictly descends.
- fetch-depth bumped to 1000 so merge-base has the history it needs.
- Release tag flow unchanged (unique tag, no race).
the esbuild pipeline (scripts/build.mjs) already bundles ink into a
single self-contained dist/entry.js.
remove the Dockerfile steps that manually copied packages/hermes-ink
into node_modules/@hermes/ink and ran a nested
npm install there.
- Dockerfile: simplify TUI build step to just 'npm run build'
- hermes_cli/main.py: _tui_build_needed now checks dist/entry.js
staleness against source files before falling back to the old
ink-bundle.js logic
- tests: update TUI npm install tests and drop the Dockerfile contract
test for the removed ink materialization step
Replace the tsc + babel pipeline with a single esbuild invocation that
produces a self-contained dist/entry.js. The nix TUI derivation no
longer copies node_modules — only dist/ + package.json ship, shrinking
the output from hundreds of MB to ~2.9 MB.
- ui-tui/scripts/build.mjs: new esbuild bundler. Aliases @hermes/ink
to source (esbuild's __esm helper doesn't await nested async init,
which breaks lazy-assigned exports like 'render' when re-exporting
through a prebuilt submodule). Stubs react-devtools-core (dev-only).
Injects a createRequire shim for transitive CJS deps. Strips the
shebang from src/entry.tsx because Nix patchShebangs mangles
'/usr/bin/env -S node --max-old-space-size=8192 --expose-gc' — it
drops the 'node' token. The Python launcher always invokes node
explicitly, so the shebang is redundant.
- nix/tui.nix: installPhase no longer copies node_modules or the
@hermes/ink packages dir.
- nix/checks.nix: drop the 'node_modules present' assertion.
- hermes_cli/main.py: _tui_need_npm_install short-circuits when
dist/entry.js exists and no package-lock.json is present. That is
the prebuilt-bundle layout (nix / packaged release) and there is
nothing to install. Without this, the launcher tried to npm install
in a non-existent site-packages/ui-tui path.
* change(nix): dedupe nix lockfile checking scripts in ci
* feat(nix): make .#fix-lockfiles run --apply if no args passed
* fix(nix): use same nodejs version everywhere & small lints
- prevent lockfile thrashing while using nix :3
- use lib.getExe instead of raw /bin/ paths
- use inputs'.self instead of passing system in manually
* fix(nix): update lock files yet again (hopefully for the last time)
* fix(nix): align indentation of collision check echo
---------
Co-authored-by: Hermes Agent <hermes@nousresearch.com>
feat(gateway): refine Platform._missing_ and platform-connected dispatch
Restricts plugin-name acceptance to bundled plugin scan + registry
(no arbitrary string -> enum-pollution), pulls per-platform connectivity
checks into a _PLATFORM_CONNECTED_CHECKERS lambda map with a clean
_is_platform_connected method, and adds tests covering the checker map,
plugin platform interface, and IRC setup wizard.
Nix-built hermes only copied skills/ into the output, so bundled platform
plugins weren't discoverable when running `nix run` (IRC invisible, no
plugin.yaml files present). Mirror the bundled-skills pattern:
- packages.nix: cleanSourceWith plugins/, copy to
$out/share/hermes-agent/plugins, set HERMES_BUNDLED_PLUGINS on every
wrapper.
- checks.nix: new bundled-plugins check verifying the directory, a
sample manifest, and the wrapper env var.
- hermes_cli.plugins.get_bundled_plugins_dir(): central helper that
honors HERMES_BUNDLED_PLUGINS with a dev-checkout fallback. Used by
plugins.py, plugins_cmd.py, gateway.py, and web_server.py so every
call site resolves the same path.
Merge the two gateway setup paths (hermes setup gateway + hermes gateway
setup) to use a single _unified_platforms() list that merges built-in
_PLATFORMS with dynamically registered plugin entries from
platform_registry.
- Add setup_fn field to PlatformEntry for plugin setup flows
- _unified_platforms() merges built-ins with registry entries by key
- setup_gateway() now uses unified list instead of hardcoded
_GATEWAY_PLATFORMS tuple list
- gateway_setup() uses same unified list, plugin entries appear
alongside built-ins with no [plugin] suffix
- _platform_status() handles plugin platforms via registry check_fn
- Plugin platforms with setup_fn get called directly; plugins without
get a generic env-var display fallback
IRC and other plugin platforms now appear automatically in the setup
menu when registered via platform_registry.register().
feat(gateway): surface disabled platform plugins in setup and auto-enable on select
Platform plugins under plugins/platforms/* (IRC, etc.) were gated behind
plugins.enabled, so `hermes gateway setup` wouldn't list them until the
user ran `hermes plugins enable <name>` first. Now the setup menu always
surfaces them as "plugin disabled — select to enable", and picking one
adds it to plugins.enabled before running its setup flow.
Along the way, unify the two gateway setup flows so `hermes setup gateway`
and `hermes gateway setup` both read from the same platform list (built-in
_PLATFORMS + platform_registry entries), dispatch through a single
_configure_platform() helper, and share _platform_status(). Deletes the
dead bespoke wrappers in setup.py (_setup_whatsapp, _setup_weixin,
_setup_email, etc.) that duplicated logic now covered by the registry
path or _setup_standard_platform.
Also:
- PlatformEntry gains a plugin_name field so the registry knows which
plugin owns each entry (required for auto-enable).
- PluginContext.register_platform auto-stamps plugin_name from the
manifest so plugins don't have to pass it explicitly.
- PluginManager now scans plugins/platforms/* as its own category root,
one level below the bundled plugin scan.
- Fix IRC plugin discovery: rename PLUGIN.yaml → plugin.yaml (the
scanner is case-sensitive) and add the missing __init__.py that
_load_directory_module requires.
Pull the top-level + chat parser construction out of main() into
hermes_cli/_parser.py so relaunch.py can introspect parser._actions to
discover which flags exist and whether they take values, instead of
maintaining a parallel hand-rolled (flag, takes_value) tuple list.
- _parser.py: build_top_level_parser() returns (parser, subparsers,
chat_parser); side-effect-free import.
- main.py: ~290 lines of inline parser construction collapsed to a
helper call. Other subparsers stay inline (dispatch is bound to
module-level cmd_* functions).
- _parser._inherited_flag(parser, ...): wraps parser.add_argument and
sets action.inherit_on_relaunch = True. Used in place of
parser.add_argument for the 25 flags (top-level + chat) that need to
carry over.
- _parser.PRE_ARGPARSE_INHERITED_FLAGS: holds --profile/-p, which
isn't on argparse (consumed earlier by main._apply_profile_override).
- relaunch.py: drops _CRITICAL_DESTS and _PRE_ARGPARSE_FLAGS; the table
builder now filters by getattr(action, 'inherit_on_relaunch', False).
- test_ignore_user_config_flags.py: brittle inspect.getsource grep
replaced with proper parser introspection.
- test_relaunch.py: introspection sanity tests added.
Salvaged from PR #17549; added top-level -t/--toolsets flag to
_parser.py so #17623 (fix(tui): honor launch toolsets) behavior is
preserved on current main.
Co-authored-by: ethernet <arilotter@gmail.com>
Extract all os.execvp('hermes', ...) calls into a utility so flags like
--tui, --dev, --profile, --model, --provider, et al. survive session
resume and post-setup relaunch.
- resolve_hermes_bin: prefers sys.argv[0] when callable, then PATH,
then falls back to '${sys.executable} -m hermes_cli.main' (fixes nix
run relaunches)
- build_relaunch_argv: allowlists critical flags so they carry over
- cmd_sessions browse now calls relaunch(['--resume', <id>])
- _apply_profile_override skips redundant work when HERMES_HOME is
already set (child inherits parent profile)
- setup.py replaces _resolve_hermes_chat_argv with relaunch_chat()
- added comprehensive tests for flag extraction and binary resolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
check_for_updates() looked at __file__.parent.parent for a .git dir to
diff against origin/main. A nix-built hermes lives in /nix/store with
no .git there, so the check fell through to whatever editable-install
dev checkout last populated ~/.hermes/.update_check, producing stale
"X commits behind" warnings right after a fresh `nix run --refresh`.
Embed the locked flake rev into the wrapper as HERMES_REVISION (only
on
clean builds — dirty refs don't represent any upstream commit). When
set, banner.py compares it to upstream main via `git ls-remote`
instead
of inspecting a local checkout, and the cache key includes the rev so
nix updates invalidate immediately. Without local history we can't
count commits, so the message is a plain "update available" with no
suggested command — nix users may install via `nix run`, profile,
system flake, or home-manager, and we don't know which.
Also bump web/package-lock.json npmDepsHash via `nix run
.#fix-lockfiles`.
FloatingOverlays (SessionPicker, ModelPicker, SkillsHub, pager,
completions) was nested inside the !isBlocked guard in ComposerPane.
When any overlay opened, isBlocked became true, which removed the
entire composer box from the tree — including the overlay that was
trying to render. This made /resume with no args appear to do nothing
(the input line vanished and no picker appeared).
Since 99d859ce (feat: refactor by splitting up app and doing proper
state), isBlocked gated only the text input lines so that
approval/clarify prompts and pickers rendered above a hidden composer.
The regression happened in 408fc893 (fix(tui): tighten composer — status
sits directly above input, overlays anchor to input) when
FloatingOverlays was moved into the input row for anchoring but
accidentally kept inside the !isBlocked guard.
so here, we render FloatingOverlays outside the !isBlocked guard inside
the same position:relative Box, so overlays
stay visible even when text input is hidden. Only the actual input
buffer lines and TextInput are gated now.
Fixes: /resume, /history, /logs, /model, /skills, and completion
dropdowns when blocked overlays are active.
Adds a per-ink-text measurement cache keyed by width|widthMode to avoid
re-squashing and re-wrapping the same text when yoga calls measureFunc
multiple times per frame with different widths during flex layout re-pass.
- build tui.nix, copy to $out/ui-tui/ (same layout as dev)
- set HERMES_TUI_DIR, HERMES_PYTHON in wrapper
- add passthru.devShellHook with stamp-checked venv setup
- expose tui as separate package output
- Switch tsconfig to nodenext module resolution for Node 22 (used by
installer script)
- Add shebang to entry.tsx, preserved into index.js
- Add HERMES_ROOT env var fallback for repo root resolution