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).
* 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