Skip to content

chore: sync upstream NousResearch/hermes-agent@main#3

Merged
hawknewton merged 103 commits into
mainfrom
chore/merge-upstream-20260515
May 15, 2026
Merged

chore: sync upstream NousResearch/hermes-agent@main#3
hawknewton merged 103 commits into
mainfrom
chore/merge-upstream-20260515

Conversation

@hawknewton

Copy link
Copy Markdown

Summary

Routine sync of NousResearch/hermes-agent@main into AmbulnzLLC/hermes-agent@main.

  • Upstream commits merged: 76 (cd64bed55..77276070f)
  • Strategy: plain merge (git merge upstream/main), zero conflicts
  • Fork-specific work preserved: all ✓
    • plugins/platforms/teams/ (untouched by upstream this window)
    • tools/_running_adapters.py (untouched)
    • tools/send_message_tool.py (untouched)
    • tools/lazy_deps.py — fork's platform.teams entries intact, upstream additions layered in
    • gateway/platforms/base.py — fork's cache_video_from_url helper intact, upstream additions layered in

Notable upstream changes in this window

A subset of the 76 commits — full list via git log upstream/main ^origin/main:

77276070f fix(codex-runtime): de-dup [plugins.X] tables and stop leaking HERMES_HOME into config.toml
274217316 fix(codex-runtime): keep migrated root keys top-level
13c72fb48 fix(tools): wrap browser provider network calls with error handling
6af994232 fix(url-safety): allow only http and https schemes
837395685 fix(slack): guard split()[0] against whitespace-only command text
94bdc63ff chore(release): add AUTHOR_MAP entry for nidhi-singh02
eacb398f7 fix(tools): add return_exceptions to asyncio.gather in web_tools
5301cc212 chore(release): add AUTHOR_MAP entry for nidhi-singh02
c4a21d783 fix(cli): log swallowed exception in runtime model auto-detection
59c7cc64f chore(release): add AUTHOR_MAP entry for amethystani
55f3262e7 fix(mcp): pre-compile env-var regex and unify interpolation
5360b5424 fix(providers): set User-Agent on ProviderProfile.fetch_models
647cc0bb0 chore(release): add AUTHOR_MAP entries for InB4DevOps
4f8aaf104 perf(run_agent): accumulate length-continuation prefix via list+join
b6e07417c feat(cli): show YOLO mode warning in banner and status bar
47614dbfc chore: wire simplex docs into sidebar + AUTHOR_MAP
09d9724a0 feat(gateway): add SimpleX Chat platform plugin
85782a4ed feat(acp): hermes acp --setup-browser bootstraps browser tools for registry installs
9f57f2286 chore(release): add AUTHOR_MAP entry for buntingszn
6682f91b8 feat(cron): support name-based lookup for job operations
05d9f641c docs(cron): worked recipes for the wakeAgent pre-run gate (#26229)
9329e0669 feat(image-gen): actionable setup message when no FAL backend is reachable (#26222)
04b1fdaec security(deps): add upper bounds to 5 loose deps + document supply chain policy (#24226)
681778a0b fix(whatsapp): fail fast when Baileys sendMessage hangs
0161d4bb6 chore(release): add AUTHOR_MAP entry for CoinTheHat
... (51 more)

Highlights worth a sanity check before merge:

  • feat(gateway): add SimpleX Chat platform plugin — new platform adapter, no impact on Teams/Mattermost wiring
  • feat(cli): show YOLO mode warning in banner and status bar
  • security(deps): add upper bounds to 5 loose deps — verify image build still resolves
  • fix(codex-runtime): de-dup [plugins.X] tables and stop leaking HERMES_HOME into config.toml — config-toml writers
  • feat(skills-hub): add huggingface/skills as trusted default tap
  • Two new GitHub Actions workflows from upstream (supply-chain-audit.yml, upload_to_pypi.yml) — additive, no impact on fork CI unless secrets are configured

Test plan

  • CI passes (existing fork tests including all PR feat(teams): outbound files (FileConsent + SharePoint) + inbound Graph fallback #2 Teams test suites)
  • Image build (build-push.sh) resolves deps cleanly with the new upper bounds
  • Smoke test in pilot pod after roll: text echo, image paste (BF-bearer path), file send (FileConsent flow), /help, /model
  • No regression in _pending_uploads cap, FileConsent card cleanup, or wildcard-MIME extension resolution

Merge mechanics

  • No squash — preserves upstream commit history for git log/git blame archaeology
  • Merge commit, not rebase — fork retains its own commits as a coherent line; future syncs replay cleanly

stephenschoettler and others added 30 commits May 13, 2026 17:32
…nstall

setup_path() writes the user-facing hermes shim with `cat >`, which
follows existing symlinks. Older installs created
`$command_link_dir/hermes` as a symlink to `$HERMES_BIN`
(`venv/bin/hermes`), so re-running install.sh stomped the pip entry
point with a bash shim that exec'd itself in an infinite loop.

`rm -f` the link target before writing so the shim lands at
`$command_link_dir/hermes` and the venv entry point is left intact.

Adds a regression test that reproduces the symlink-stomp end-to-end
(creates the symlink, drives the real shim-write block from setup_path,
asserts the venv pip script body survives and the shim is now a regular
file). Both new assertions fail on origin/main and pass with the fix.

Closes NousResearch#21454.
Brings Discord to parity with Telegram on the clarify tool's interactive
UX. Overrides BasePlatformAdapter.send_clarify on DiscordAdapter to attach
a button view when choices are present.

  - ClarifyChoiceView: one discord.ui.Button per choice (max 24, Discord's
    25-component view cap leaves one slot for Other) plus a final
    'Other (type answer)' button.
  - Numeric click -> tools.clarify_gateway.resolve_gateway_clarify(
    clarify_id, choice_text) using the canonical choice text from the
    gateway entry (falls back to the button label if the entry vanished).
  - Other click -> tools.clarify_gateway.mark_awaiting_text(clarify_id) so
    the gateway's text-intercept captures the next user message in this
    session as the response.
  - Auth via the shared _component_check_auth helper (same OR-semantics as
    ExecApprovalView / SlashConfirmView / UpdatePromptView / ModelPickerView).
  - Open-ended (no choices) path renders the prompt as a plain embed and
    relies on the existing text-intercept resolution.
  - Single-use: first valid click disables every button and updates the
    embed footer with who answered and what they chose.

No changes to BasePlatformAdapter.send_clarify or the gateway's
clarify_callback wiring -- the existing scaffolding already drives all
adapters; Discord just inherits the default text fallback today and gains
buttons by virtue of this override.

Test conftest extended: _FakeEmbed gains add_field() / set_footer() stubs
so tests can construct embedded views without monkey-patching per-test.

Original PR: NousResearch#19249 by @LeonSGP43. This is a reshape of the contributor's
work onto current main's clarify infrastructure (clarify_id + entry-based
resolution shared with Telegram, instead of a parallel on_answer-closure
mechanism). The button view structure and UX shape are preserved.

Tests: 14 new tests in tests/gateway/test_discord_clarify_buttons.py.
391/391 existing Discord gateway tests still pass.

Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
…s model` flow (NousResearch#25750)

Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already
set in ~/.hermes/.env, `hermes model openrouter` / `hermes model
ai-gateway` skipped the API-key prompt entirely and jumped straight to
the model picker. Users with a broken / expired / wrong key had no way
to replace it without editing ~/.hermes/.env by hand or re-running
`hermes setup` from scratch.

Both flows now route through the existing `_prompt_api_key()` helper,
which surfaces [K]eep / [R]eplace / [C]lear when a key is already
configured — the same UX the generic API-key providers (z.ai, MiniMax,
Gemini, etc.) and the Daytona setup already use.
…ndows (NousResearch#25755)

* fix(cli): allow rotating broken OpenRouter / AI Gateway key in `hermes model` flow

Before: when `OPENROUTER_API_KEY` (or `AI_GATEWAY_API_KEY`) was already
set in ~/.hermes/.env, `hermes model openrouter` / `hermes model
ai-gateway` skipped the API-key prompt entirely and jumped straight to
the model picker. Users with a broken / expired / wrong key had no way
to replace it without editing ~/.hermes/.env by hand or re-running
`hermes setup` from scratch.

Both flows now route through the existing `_prompt_api_key()` helper,
which surfaces [K]eep / [R]eplace / [C]lear when a key is already
configured — the same UX the generic API-key providers (z.ai, MiniMax,
Gemini, etc.) and the Daytona setup already use.

* fix(install.ps1): pin uv sync target to venv\, verify baseline imports

Two related Windows-installer bugs that produce a broken venv with
`ModuleNotFoundError: No module named 'dotenv'` on first `hermes` run.

## Bug 1: uv sync ignores VIRTUAL_ENV, syncs into .venv\ instead of venv\

`Install-Dependencies` creates the venv at `venv\` via `uv venv venv`,
sets `$env:VIRTUAL_ENV = "$InstallDir\venv"`, then runs
`uv sync --extra all --locked`. Modern uv (>=0.5) ignores `VIRTUAL_ENV`
for the `sync` subcommand and uses the project default `.venv\`
instead. Result: deps land in `$InstallDir\.venv\`, `venv\` stays
empty except for the python.exe stub from the earlier `uv venv` call,
`hermes.exe` ends up wired to the wrong site-packages.

The bash installer (`scripts/install.sh`) already worked around this in
`install_deps()` line 1127 by passing `UV_PROJECT_ENVIRONMENT` — that
flag tells uv exactly where to put the project env regardless of
`VIRTUAL_ENV`. Port the same fix to PowerShell.

## Bug 2: no post-install verification

If the sync still misdirects for any other reason (uv version drift,
filesystem quirk, user re-run scenarios), the installer reports success
and the user only finds out by running `hermes` and getting an
unhelpful traceback. Add a baseline-import probe that runs the venv's
own python against the four packages every `hermes` invocation needs
(`dotenv`, `openai`, `rich`, `prompt_toolkit`). On failure, throw
with a recovery command tailored to whether a sibling `.venv\` exists.

User report (Windows 11, Python 3.13.5, Hermes v0.13.0): manual repro
steps were exactly this — `uv sync` landed in `.venv\`, recovered by
junctioning `venv\` → `.venv\` to bridge the path mismatch.
Use MarkdownV2 formatting for Telegram callback follow-ups and interactive prompts where dynamic names or user text can break legacy Markdown parsing. Add regression coverage for reload-mcp, model picker, approval callbacks, and update prompts.
The cherry-picked PR over-indented the edit_message_text block for
the mm: (model selected → switch) success path so the confirmation
edit lived inside the preceding 'except Exception as exc' branch and
only fired when the callback raised. Dedent the try/except back to
12-space indent so it runs after the callback succeeds, restoring
the original flow that removes the inline buttons and shows the
'Switched to ...' confirmation.

Add a regression test (test_model_selected_edits_message_on_success)
that asserts edit_message_text is awaited and the result text is
routed through format_message (MARKDOWN_V2 + backtick survival).

Add phuongvm to scripts/release.py AUTHOR_MAP.
…th refresh classify (NousResearch#25769)

Mirrors openclaw beta.8's app-server resilience fixes so a stuck codex
subprocess can't burn the full turn deadline and so users get a
`codex login` pointer instead of raw RPC errors when their token expires.

- TurnResult.should_retire signals the caller to drop+respawn codex.
- Deadline-hit path and dead-subprocess detection set should_retire so
  the next turn doesn't ride a CPU-spinning or auth-broken process.
- Post-tool watchdog (post_tool_quiet_timeout=90s): if a tool item
  completes and codex goes silent past the threshold without further
  output or turn/completed, fast-fail instead of waiting the full 600s.
  Resets on any non-tool activity so normal think-after-tool flows are
  not affected.
- <turn_aborted> and <turn_aborted/> in agent text are treated as
  terminal — some codex builds tear down a turn that way without
  emitting turn/completed.
- _classify_oauth_failure() inspects RPC error message + stderr tail
  for invalid_grant / token refresh / 401 / etc. and rewrites
  user-facing errors to 'run codex login'. Conservative: generic
  failures still surface verbatim. Fires at turn/start failure,
  turn/completed failure, and dead-subprocess paths.
- thread/start cross-fill: tolerate thread.id, thread.sessionId,
  top-level sessionId/threadId so future codex schema drift doesn't
  KeyError us at handshake.
- run_agent.py: when run_turn returns should_retire=True OR raises,
  close + null self._codex_session so the next turn respawns.

Tests: +30 cases across session + integration suites.
  tests/agent/transports/test_codex_app_server_session.py 50/50 pass
  tests/run_agent/test_codex_app_server_integration.py 27/27 pass
  Broader codex scope (transports + cli runtime/migration) 376/376 pass
Pre-stages AUTHOR_MAP for 7 new contributors in the upcoming batch:

- HxT9          (NousResearch#25760)
- evgyur        (NousResearch#25651)
- AsoTora       (NousResearch#25624)
- oxngon        (NousResearch#25603)
- yifengingit   (NousResearch#25589)
- vanthinh6886  (NousResearch#25562)
- Arkmusn       (NousResearch#25559)

EthanGuo-coder, wesleysimplicio, and zccyman are already in the map.
The _approval_callback method in HermesCLI hardcoded timeout=60
instead of reading the approvals.timeout config value. This meant
the config setting was silently ignored for CLI interactive prompts.

Other approval paths (callbacks.py, tools/approval.py) already read
the config correctly — only cli.py was missed.
On WSL2 (and similar environments), time.time() is not strictly monotonic
due to NTP sync or host clock adjustments. When clock regression occurs
during a multi-tool flush, later-inserted rows get earlier timestamps,
causing ORDER BY timestamp, id to sort them before rows that were written
first. This breaks the tool_calls/tool_response adjacency invariant and
triggers HTTP 400 from the API.

Use ORDER BY id instead, since id (INTEGER PRIMARY KEY AUTOINCREMENT)
always reflects true insertion order regardless of system clock behavior.
Set file mode 0600 on ~/.hermes/.env after creation in the installer and
after every write via memory_setup._write_env_vars(). This ensures only
the file owner can read/write API keys and tokens, matching standard
practice for credential files (.netrc, .aws/credentials, .ssh/config).

Fixes NousResearch#25477
When the gateway spawned a background agent (e.g. for delegation), media
URLs and types from the originating message weren't forwarded — the bg
agent saw the prompt but no attached images. Vision-enabled tasks
effectively lost their inputs.

Forwards media_urls/media_types through the bg-task spawn path and
runs the same vision-enrichment step the main flow uses, so the bg
agent gets image descriptions inlined into its prompt.

Closes NousResearch#25614.

Salvage of NousResearch#25603 by @oxngon (manually re-applied — original branch
was severely stale against current main).
…de quoted strings

The _foreground_background_guidance() function matched background-wrapper
keywords (nohup/disown/setsid) anywhere in the command text, including
inside quoted strings, Python -c code, commit messages, and PR body text.

Two-layer fix:
1. Strip single-quoted, double-quoted, and backtick-quoted content before
   pattern matching via _strip_quotes() helper.
2. Tighten the regex to only match keywords at command-start positions
   (after ^, ;, &, &&, ||, or $() — not mid-argument.

Both layers are needed: quote stripping handles the common case of keywords
in string literals, and the position-aware regex handles unquoted cases
like 'export FOO=setsid' (word boundary match, wrong position).

Fixes NousResearch#20064
Co-Authored-By: Oswald <oswaldb22@users.noreply.github.com>
Adds regression tests pinning web search into the WhatsApp and api-server
default platform-coverage toolsets. Pure test additions, no runtime change.

Salvage of the test-addition commit from NousResearch#25692 by @wesleysimplicio.
(The AUTHOR_MAP fixup commit from the same PR landed separately as
529ec85.)
…search#25766)

Pyproject's [all] extra was slimmed down in May 2026 — ~20 optional
backends moved to tools/lazy_deps.py and only install on first use.
hermes update runs uv pip install -e .[all] which doesn't touch any of
them, so pin bumps in LAZY_DEPS (CVE response, transitive fixes) were
silently ignored on already-activated backends.

Two changes:

1. _is_satisfied() now parses the spec and checks the installed version
   against the constraint via packaging.specifiers. Previously it
   returned True the moment the package name was importable, which made
   ensure() a name-presence gate rather than a version-pin gate.

2. New active_features() / refresh_active_features() pair: lists every
   feature with at least one of its packages currently installed, then
   re-runs ensure() on each. Refresh is invoked at the end of
   _cmd_update_impl, right after the [all] install completes. Cold
   backends (never activated) stay quiet — no churn for them.

Output during update is one summary block:
  → Refreshing 4 active lazy backend(s)...
    ↑ 1 refreshed: provider.anthropic
    ✓ 3 already current
or
    ⚠ memory.honcho failed to refresh: <pip stderr>

Failures never raise out of update — backends keep their previously-
installed version and we tell the user to rerun once upstream is fixed.
security.allow_lazy_installs=false is honored: features get marked
"skipped" with the reason shown.

Tests: 18 new unit tests covering version-aware satisfaction (exact pin,
range, extras blocks, missing package, malformed spec), active feature
discovery, and refresh status reporting. All 61 lazy_deps tests pass.
…tream chunks

_make_stream_chunk built delta_kwargs with only `role`, so a reasoning-only
chunk produced a SimpleNamespace without a `.content` attribute. Downstream
consumers that read `delta.content` then raised AttributeError on Gemini 2.5
Flash, where the thinking delta arrives before any content delta.

Seed `content`, `tool_calls`, `reasoning`, and `reasoning_content` as None
up front, matching the pattern already used in gemini_native_adapter.py.
Key-present arguments still override the defaults.

Fixes NousResearch#24974
References: Related open PR NousResearch#24984 (luyao618) applies the same 1-line fix; this PR adds a regression test that NousResearch#24984 omits
Co-Authored-By: Claude <noreply@anthropic.com>
…ousResearch#25814)

The Debian/Ubuntu branch of install_node_deps() ran 'npx playwright install
--with-deps chromium' unconditionally. Playwright invokes sudo interactively
to apt-install Chromium's system libraries, which blocks the installer for
non-sudo users (systemd service accounts, unprivileged operator users) on
an unsatisfiable password prompt.

Changes:
- install.sh: gate --with-deps behind a sudo capability check on the apt
  branch (matches the existing Arch/pacman branch pattern). Non-sudo users
  fall back to 'npx playwright install chromium' alone and the installer
  prints the exact 'sudo npx playwright install-deps chromium' command an
  administrator can run separately.
- install.sh: add --skip-browser (alias --no-playwright) to skip the
  Playwright step entirely for headless installs that don't need browser
  automation. Mirrors the existing --no-venv / --skip-setup shape.
- installation.md: add a 'Non-Sudo / System Service User Installs' section
  covering the admin/service-user split, the --skip-browser flag, and the
  ~/.local/bin PATH gotcha (the root cause of the 'No module named dotenv'
  error users hit when running the repo source 'hermes' script with system
  Python instead of the venv launcher).
- test_install_sh_browser_install.py: regression coverage for the
  --skip-browser flag and the sudo-gate on the apt branch.

Reported by @ssilver in Discord.
…sResearch#25828)

Adds references/template-integrity.md covering safe conversion of the
official comfyui-workflow-templates package from editor format to API
format — Reroute bypass via link tracing, dotted dynamic-input keys
(values.a, resize_type.width) that must NOT be flattened, server-error
"patch don't rebuild" loop, Cloud quirks (302 redirect to signed GCS
URL, free-tier 1 concurrent job, 1920x1080 OOM on RTX 5090), and a
Discord-compatible ffmpeg stitch recipe (yuv420p + xfade/acrossfade).

SKILL.md lists the new reference so the agent loads it when starting
from an official template. purzbeats added to author list and to
scripts/release.py AUTHOR_MAP.

Co-authored-by: purzbeats <97489706+purzbeats@users.noreply.github.com>
…agent dispatch (NousResearch#25845)

WhatsApp pseudo-chats (Status updates / Stories, Channels / Newsletters,
broadcast lists) were being routed through the full agent pipeline. A
user's gateway.log showed the agent replying to a contact's Story
('status@broadcast') with 345 chars plus title-generation cost, which
also shows up in the contact's status feed.

Drop these JIDs at _should_process_message() before the policy gate so
they're filtered regardless of dm_policy or allowlist state. Covers:
- status@broadcast (Stories)
- *@newsletter (Channels)
- *@broadcast (broadcast lists, future-proofing)

The bridge.js already filters these on the fromMe outbound path, but
inbound events on self-chat mode skipped that check.

Tests:
- status@broadcast dropped on open policy
- broadcast filter wins over allowlisted senders
- real DMs still pass through
- helper unit cases (case-insensitive, whitespace-tolerant)

26/26 tests/gateway/test_whatsapp_group_gating.py pass; 59/59 adjacent
WhatsApp test suites pass.
…r-check-unblock

fix(ci): unblock shared PR checks
… not skipped

When the final streamed text is identical to the last plain-text edit,
stream_consumer._send_or_edit short-circuits and never calls
adapter.edit_message(finalize=True).  For Telegram, this skips the
plain-text → MarkdownV2 conversion, leaving raw Markdown syntax visible
to the user.

Set REQUIRES_EDIT_FINALIZE = True on TelegramAdapter so the finalize
edit is always delivered, matching the existing DingTalk pattern.

Fixes NousResearch#25710
`hermes config set gateway.streaming.*` writes the streaming block
nested under a `gateway:` key in config.yaml, but the config loader
only checked for a top-level `streaming:` key — silently ignoring
the nested variant.

Fall back to `yaml_cfg['gateway']['streaming']` when the top-level
key is absent, matching the pattern already used for other nested
config sections.

Closes NousResearch#25676
…iled

When the stream consumer's got_done handler successfully delivers the
final response content via _send_or_edit but the subsequent edit
(e.g. cursor removal) fails, final_response_sent remains False even
though the user has already received the final answer. The gateway's
fallback send path then re-delivers the same content, causing the
user to see the response twice on Telegram.

Introduce a new _final_content_delivered flag on the stream consumer,
set by the got_done handler when the final content has reached the
user. The _run_agent suppression logic now treats this flag as an
additional signal (alongside final_response_sent and
response_previewed) that final delivery is already complete.

This preserves the existing behavior for intermediate-text-only
streams (where already_sent=True but no final content has been
delivered) — those still receive the gateway's fallback send, matching
the test expectation in test_partial_stream_output_does_not_set_already_sent.

Adds TestFinalContentDeliveredSuppression with two cases covering
both the suppression (content delivered + edit failed) and the
non-suppression (intermediate text only) branches.
alt-glitch and others added 24 commits May 15, 2026 01:33
…ain policy (NousResearch#24226)

After the Mini Shai-Hulud supply chain campaign (May 2026) and the litellm
compromise (March 2026), codify the dependency pinning policy that was
established in PRs NousResearch#2810 and NousResearch#9801 but never written down for contributors.

Changes:
- pyproject.toml: Add tight upper bounds to the 5 deps that slipped
  through as review escapes from external contributor PRs:
  - hindsight-client>=0.4.22,<0.5 (was >=0.4.22)
  - aiosqlite>=0.20,<0.23 (was >=0.20)
  - asyncpg>=0.29,<0.32 (was >=0.29)
  - alibabacloud-dingtalk>=2.0.0,<3 (was >=2.0.0)
  - youtube-transcript-api>=1.2.0,<2 (was >=1.2.0)

  Pre-1.0 packages get <0.(current_minor+2) — tight enough to block
  hostile minor releases but loose enough to not require bumps every week.

- CONTRIBUTING.md: Add 'Dependency pinning policy' section under Security
  with the full rationale, table of source types + treatments, and examples.

- AGENTS.md: Add concise 'Dependency Pinning Policy' section for AI coding
  agents with the decision table and step-by-step checklist.

- supply-chain-audit.yml: Add dep-bounds job that fails PRs introducing
  PyPI deps without <ceiling upper bounds. Fires on pyproject.toml changes.
  Posts a PR comment with the specific unbounded specs found.

Refs: NousResearch#2796 NousResearch#2810 NousResearch#9801 NousResearch#24205
…hable (NousResearch#26222)

When the in-tree FAL path has no API key (and no managed gateway), the
handler used to return a bare 'FAL_KEY environment variable not set'
error. Users had no idea where to get a key, that a managed Nous
gateway exists, or that plugin-registered providers are an option.

Now `image_generate_tool` returns a structured multi-line message:
  - signup link (https://fal.ai)
  - managed-gateway status (if Nous tools are enabled)
  - pointer to `hermes tools` / `hermes plugins list` for alternate
    backends, so users on a stale `image_gen.provider` know where to look

The schema is untouched — `check_fn` still gates the tool out of the
schema when no backend is reachable at startup, consistent with every
other conditional tool. This patch fixes the call-time failure modes:
managed-gateway 5xx, plugin provider disappearing mid-session, etc.

Inspired by NousResearch#2546 / @Mibayy. The PR was ~5700 commits stale against
the new plugin-aware image_gen architecture, so this is a forward port
of the actionable-error idea rather than a cherry-pick.


Closes NousResearch#2543

Co-authored-by: Mibayy <mibayy@users.noreply.github.com>
…ch#26229)

Adds three pre-run gate recipes to the cron docs:
- file-change gate (stat + mtime + state file)
- external-flag gate (file presence)
- SQL-count gate (user's own database, not state.db)

These are the use cases @iankar8 proposed adding as a parallel
'trigger' subsystem in NousResearch#2654. The existing `script` + `wakeAgent`
gate already covers all three at $0 — this lands the patterns as
documentation so users can find them, instead of adding a second
gating mechanism to the cron subsystem.
Cron mutation operations (run/pause/resume/remove) and 'hermes cron edit'
now accept a job name in addition to the hex ID, with case-insensitive
matching. Before this, 'hermes cron run my_job_name' died with
'Job with ID my_job_name not found' and forced the user to look up the
hex ID first.

The original PR matched by name but silently picked the first match when
two jobs shared a name. This version refuses to act on an ambiguous name
and surfaces every matching job (id, name, schedule, next_run_at) so the
caller can pick a specific ID.

- cron/jobs.py:
  - get_job() stays ID-only (preserves existing call-site semantics for
    web_server/api_server/curator/scheduler/test code that always passes
    real IDs).
  - resolve_job_ref() is the new name-or-ID resolver, used by pause/
    resume/trigger/remove_job. Exact ID match wins over a name match
    even if a different job's name happens to equal that ID. Ambiguous
    name match raises AmbiguousJobReference with all candidate IDs.
- tools/cronjob_tools.py: dispatch site uses resolve_job_ref, surfaces
  ambiguous matches as a structured error with the matching IDs.
- hermes_cli/cron.py: 'cron edit' uses resolve_job_ref so editing by
  name works and ambiguous names are reported with IDs.
- tests/cron/test_jobs.py: new TestResolveJobRef covering ID match,
  case-insensitive name match, ID-wins-over-name, ambiguous refusal,
  and that pause/resume/trigger/remove all refuse on ambiguity.

Closes NousResearch#2627
…gistry installs

The Zed ACP Registry path (uvx --from 'hermes-agent[acp]==X' hermes-acp)
gets a Python-only install. Browser tools depend on the agent-browser npm
package + Chromium, neither of which are in the wheel. Without an
explicit bootstrap, registry users have no path to working browser tools.

Ship a bundled, idempotent bootstrap script (Linux/macOS bash + Windows
PowerShell) inside acp_adapter/bootstrap/ as wheel package-data. New
entry points:

  hermes acp --setup-browser        # interactive; prompts before Chromium download
  hermes acp --setup-browser --yes  # non-interactive
  hermes-acp --setup-browser

The terminal-auth flow (hermes acp --setup) also offers the browser
bootstrap as a follow-up after model selection, so first-run registry
users get the option without knowing the flag exists.

Key design choices:
- npm install -g --prefix $NODE_PREFIX so we never need sudo. System Node
  on PATH is respected; only the install target is redirected to the
  user-writable Hermes-managed Node prefix.
- tools/browser_tool.py::_browser_candidate_path_dirs() already walks
  $HERMES_HOME/node/bin, so installed binaries are discovered with no
  agent-side code change.
- System Chrome/Chromium detection short-circuits the ~400 MB Playwright
  download when a suitable browser already exists.
- Bash + PowerShell live as ONE copy each under acp_adapter/bootstrap/.
  Not duplicated under scripts/. install.sh and install.ps1 keep their
  inline browser blocks for the source-checkout path.

E2E validated end-to-end:
  bash bootstrap_browser_tools.sh --skip-chromium
    → installs agent-browser into ~/.hermes/node/bin/
  tools.browser_tool._find_agent_browser()
    → returns the installed path
  check_browser_requirements()
    → returns True (browser tools register)

Tests:
- tests/acp/test_entry.py: 11 tests covering --setup-browser dispatch
  (linux + windows + --yes forwarding + failure propagation), the
  terminal-auth follow-up prompt path, and a package-data wheel-shipping
  assertion that catches any future pyproject.toml regression.

Docs: website/docs/user-guide/features/acp.md gains a 'Browser tools
(optional)' subsection with the two-line install + what-it-does.
SimpleX Chat (https://simplex.chat) is a private, decentralised messenger
with no persistent user IDs — every contact is identified by an opaque
internal ID generated at connection time. This adds it as a Hermes
gateway platform via the plugin system.

The adapter connects to a local simplex-chat daemon via WebSocket,
listens for inbound messages, and sends replies. Originally proposed in
PR NousResearch#2558 as a core-modifying integration; reshaped here as a self-
contained plugin under plugins/platforms/simplex/ with no edits to any
core file. Discovery is filesystem-based (scanned by gateway.config),
and the platform identity is resolved on demand via Platform("simplex").

Plugin contract:
- check_requirements() requires SIMPLEX_WS_URL AND the websockets package
- validate_config() / is_connected() accept env or config.yaml input
- _env_enablement() seeds PlatformConfig.extra (ws_url + home_channel)
- _standalone_send() supports out-of-process cron delivery
- interactive_setup() provides a stdin wizard for hermes gateway setup
- register() wires the adapter into the registry with required_env,
  install_hint, cron_deliver_env_var, allowed_users_env, and a
  platform_hint for the LLM.

Lazy dependency: the websockets Python package is imported inside the
functions that need it. The plugin is importable and discoverable even
when websockets is missing — check_requirements() simply returns False
until `pip install websockets` is run. No new pyproject extras are
introduced.

Environment variables:
  SIMPLEX_WS_URL             WebSocket URL of the daemon (required)
  SIMPLEX_ALLOWED_USERS      Comma-separated allowed contact IDs
  SIMPLEX_ALLOW_ALL_USERS    Set true to allow all contacts
  SIMPLEX_HOME_CHANNEL       Default contact for cron delivery
  SIMPLEX_HOME_CHANNEL_NAME  Human label for the home channel

Closes NousResearch#2557.
- Adds plugins/platforms/simplex docs page to the messaging sidebar
  between LINE and Open WebUI.
- Maps louismichalot@hotmail.com -> Mibayy in scripts/release.py so the
  attribution check on the salvage PR passes.
When running with --yolo, all dangerous command approvals are bypassed.
Make this state visible so users don't forget:

- Banner: '⚠ YOLO mode — all approval prompts bypassed' line in red, only
  shown when YOLO is active. Default case is silent (no extra line, no
  always-on 'restricted' label).
- Status bar: '⚠ YOLO' fragment appended in red (#FF4444 bold) across all
  three width tiers (<52, <76, ≥76) in both the plain-text fallback and
  the fragments builder.

Closes NousResearch#2663

Co-authored-by: Mibayy <Mibayy@users.noreply.github.com>
Replace O(n²) string concatenation of truncated_response_prefix in the
length-continuation retry loop with a list + ''.join(). Functionally
equivalent: same partial response on early return, same prepend on
final assembly. The legacy retry path is capped at 3 iterations, so
the practical wall-clock win is small, but the new idiom matches the
rest of the codebase and removes a needless repeated allocation.

Salvaged from PR NousResearch#2717 (the run_conversation portion only — trajectory
refactor dropped because it silently rewrote </tool_response> to </think>).

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Some catalog endpoints (OpenCode Zen, etc.) sit behind a WAF that
returns 403 for the default Python-urllib/<ver> User-Agent.  The
generic profile-based live fetch in providers/base.py was silently
failing for any such provider — falling through to the static catalog
and missing newly-launched models.

Set a generic 'hermes-cli/<version>' UA on the catalog probe so every
api_key provider profile benefits.  Verified live against opencode-zen:
before this change, profile.fetch_models() raised HTTP 403; after, it
returns 42 models including gpt-5.5, gpt-5.5-pro, kimi-k2.6, glm-5.1
and the *-free variants the static catalog doesn't list.

Also strip the now-stale comment in validate_requested_model() claiming
opencode-zen's /models returns 404 against the HTML marketing site —
the API endpoint at /zen/v1/models returns 200 with valid JSON.

Surfaced by NousResearch#2651 (@aashizpoudel) — fixes the same user-facing gap
their PR targeted, applied at the right layer so all api_key provider
profiles get live catalogs through the same code path.

Co-authored-by: Aashish Poudel <mr.aashiz@gmail.com>
Remove redundant inner `import re` and regex recompilation on every call in
_interpolate_env_vars. Add module-level _ENV_VAR_PATTERN compiled once.

Replace the separate _interpolate_value() in mcp_config.py (which used \w+
and would silently fail on env vars containing hyphens or dots) with the
shared _ENV_VAR_PATTERN from mcp_tool.py. Remove now-unused import re.
Replaces bare `except Exception: pass` with debug-level logging
so failures in local endpoint model discovery are diagnosable
instead of silently hidden.
Three asyncio.gather() calls in tools/web_tools.py ran without
return_exceptions=True. A single failing task (e.g. LLM rate limit on
one URL) would raise out of gather() and discard every other
successfully fetched/summarized result.

Pass return_exceptions=True and filter BaseException entries with a
warning log before unpacking. Affects:

- chunk summarization gather (large web_extract pages)
- firecrawl per-result LLM post-processing
- tavily crawl per-result LLM post-processing

Closes NousResearch#2744
PR NousResearch#2751 salvage. CI requires AUTHOR_MAP coverage for all
contributor commit emails.
When a user sends a Slack message like '/hermes   ' (trailing whitespace
after the slash) the legacy subcommand router hit `text.split()[0]` with
a truthy-but-whitespace-only `text`. `'   '.split()` returns `[]` →
IndexError, blowing up the slash handler before fallthrough to `/help`.

Switch to a two-step guard that materializes the parts list first and
indexes only if non-empty.

Salvaged from PR NousResearch#2752 by @nidhi-singh02. The PR's other two hunks
(`tools/file_operations.py`, `agent/anthropic_adapter.py`) are
unreachable in current code — `LINTERS` is a hardcoded constant dict
with no empty values, and the anthropic version-detection site is
already guarded by a `result.stdout.strip()` truthy check — so only the
slack hunk is taken.

Closes NousResearch#2745

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
Wrap requests.post() in create_session() for browser_use, browserbase,
and firecrawl providers with requests.RequestException handling.
Connection timeouts and DNS resolution failures now surface as clean
RuntimeError messages instead of raw requests exception tracebacks.

Browser Use managed-gateway mode preserves raw exception propagation
so the existing idempotency-key retry semantics keep working.

Closes NousResearch#2746

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
…_HOME into config.toml

Builds on @steezkelly's Bug A fix (NousResearch#25857, top-level default_permissions
via _insert_managed_block_at_top_level) by addressing the other two
config-corruption bugs described in NousResearch#26250:

Bug B (duplicate [plugins.X] tables)
  - Codex itself writes [plugins."<name>@<marketplace>"] tables to
    config.toml when the user runs `codex plugins enable` directly,
    before hermes-agent's managed block exists. On the next migrate run,
    _query_codex_plugins() re-discovers the same plugins via plugin/list
    and render_codex_toml_section() re-emits them inside the managed
    block. Codex's strict TOML parser then rejects the duplicate table
    header on startup.
  - Add _strip_unmanaged_plugin_tables() that drops [plugins.*] tables
    from the user-content portion of the file. Only run it when
    plugin/list succeeded — if the RPC failed we can't re-emit and
    must preserve the user's tables. plugin/list is the source of
    truth when it answers.

Bug C (HERMES_HOME pytest-tempdir leak into ~/.codex/config.toml)
  - _build_hermes_tools_mcp_entry() read HERMES_HOME directly from
    os.environ, so a sibling pytest's monkeypatch.setenv("HERMES_HOME",
    tmp_path) silently burned a transient pytest tempdir into the
    user's real ~/.codex/config.toml. After pytest reaped the tempdir,
    every codex-routed hermes-tools tool call failed silently.
  - Derive HERMES_HOME from get_hermes_home() (the canonical resolver
    that goes through the profile-aware path) and refuse to emit
    obvious test-tempdir paths via _looks_like_test_tempdir() as
    belt-and-suspenders for any other callsite that forgets to patch
    migrate().
  - test_enable_succeeds_when_codex_present in test_codex_runtime_switch.py
    invoked the real migrate() (no mock), writing to Path.home() / .codex
    using whatever HERMES_HOME the running pytest session had set. Add
    the same migrate patch the other apply() tests already use, so the
    suite stops touching the user's real ~/.codex/config.toml.

E2E verification (replicating the issue's repro):
  - Pre-state config.toml with user [mcp_servers.omx_team_run] +
    codex-installed [plugins."tasks@openai-curated"],
    HERMES_HOME="/private/var/folders/.../pytest-of-.../..."
  - On origin/main: tomllib refuses to load the result with
    "Cannot declare ('plugins', 'tasks@openai-curated') twice" AND
    the pytest-tempdir HERMES_HOME is burned in.
  - On this branch: file parses cleanly, default_permissions is
    top-level, exactly one [plugins."tasks@openai-curated"] table
    inside the managed block, no HERMES_HOME in the MCP env.

7 new regression tests covering all three bugs + the test-leak guard.
`bash scripts/run_tests.sh tests/hermes_cli/test_codex_runtime_*.py` —
95 passed, 0 failed.

Closes NousResearch#26250
@github-actions

Copy link
Copy Markdown

🚨 CRITICAL Supply Chain Risk Detected

This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.

🚨 CRITICAL: Install-hook file added or modified

These files can execute code during package installation or interpreter startup.

Files:

hermes_cli/setup.py

Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.

@github-actions

Copy link
Copy Markdown

🔎 Lint report: chore/merge-upstream-20260515 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8342 on HEAD, 8394 on base (✅ -52)

🆕 New issues (45):

Rule Count
unresolved-attribute 21
unresolved-import 14
invalid-argument-type 6
unused-type-ignore-comment 2
invalid-assignment 1
unsupported-operator 1
First entries
tests/hermes_cli/test_tools_config.py:534: [invalid-argument-type] invalid-argument-type: Argument to function `_configure_provider` is incorrect: Expected `dict[Unknown, Unknown]`, found `str | dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[Unknown]] | dict[str, str | list[dict[str, str]]] | Unknown`
tests/hermes_cli/test_bedrock_model_picker.py:39: [unresolved-attribute] unresolved-attribute: Unresolved attribute `session` on type `ModuleType`
cli.py:1488: [invalid-assignment] invalid-assignment: Object of type `def _wrapped_get_color(self, key, fallback="") -> Unknown` is not assignable to attribute `get_color` of type `def get_color(self, key: str, fallback: str = "") -> str`
hermes_cli/proxy/server.py:231: [unresolved-attribute] unresolved-attribute: Attribute `TCPSite` is not defined on `None` in union `Unknown | None`
gateway/platforms/discord.py:5508: [unused-type-ignore-comment] unused-type-ignore-comment: Unused blanket `type: ignore` directive
tests/hermes_cli/test_bedrock_model_picker.py:38: [unresolved-attribute] unresolved-attribute: Unresolved attribute `get_session` on type `ModuleType`
hermes_cli/proxy/server.py:160: [unresolved-attribute] unresolved-attribute: Attribute `ClientTimeout` is not defined on `None` in union `Unknown | None`
tests/gateway/test_discord_clarify_buttons.py:20: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/proxy/server.py:22: [unresolved-import] unresolved-import: Cannot resolve imported module `aiohttp`
run_agent.py:15545: [unresolved-attribute] unresolved-attribute: Attribute `rstrip` is not defined on `None` in union `None | str | (Unknown & ~AlwaysFalsy)`
hermes_cli/proxy/server.py:229: [unresolved-attribute] unresolved-attribute: Attribute `AppRunner` is not defined on `None` in union `Unknown | None`
cli.py:1489: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_hermes_light_mode_hook_installed` on type `<class 'SkinConfig'>`.
tests/gateway/test_simplex_plugin.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_simplex_plugin.py:313: [unresolved-import] unresolved-import: Cannot resolve imported module `websockets.client`
tests/agent/transports/test_codex_app_server_session.py:965: [invalid-argument-type] invalid-argument-type: Argument to function `_classify_oauth_failure` is incorrect: Expected `str`, found `None`
cli.py:13436: [unresolved-import] unresolved-import: Cannot resolve imported module `prompt_toolkit.renderer`
gateway/platforms/yuanbao.py:2621: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `MessageType`, found `Literal[MessageType.DOCUMENT] | Any | None`
plugins/platforms/simplex/adapter.py:632: [unresolved-import] unresolved-import: Cannot resolve imported module `websockets`
tests/gateway/test_simplex_plugin.py:260: [unused-type-ignore-comment] unused-type-ignore-comment: Unused blanket `type: ignore` directive
hermes_cli/proxy/server.py:108: [unresolved-attribute] unresolved-attribute: Attribute `Response` is not defined on `None` in union `Unknown | None`
hermes_cli/proxy/server.py:93: [unresolved-attribute] unresolved-attribute: Attribute `Application` is not defined on `None` in union `Unknown | None`
run_agent.py:15499: [invalid-argument-type] invalid-argument-type: Argument to function `len` is incorrect: Expected `Sized`, found `None | str | (Unknown & ~AlwaysFalsy)`
tests/test_gateway_streaming_nested_config.py:6: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/proxy/server.py:162: [unresolved-attribute] unresolved-attribute: Attribute `ClientSession` is not defined on `None` in union `Unknown | None`
gateway/platforms/discord.py:3730: [unresolved-attribute] unresolved-attribute: Attribute `Forbidden` is not defined on `None` in union `Unknown | None`
... and 20 more

✅ Fixed issues (110):

Rule Count
unresolved-import 63
invalid-argument-type 14
unresolved-attribute 14
invalid-assignment 12
invalid-parameter-default 2
not-subscriptable 2
unsupported-operator 1
unresolved-reference 1
invalid-return-type 1
First entries
tests/tools/test_managed_server_tool_support.py:84: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.envs.server_handling.managed_server`
tools/rl_training_tool.py:973: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["history"]` and value of type `list[dict[Unknown, Unknown]]` on object of type `dict[str, str]`
tests/hermes_cli/test_tools_config.py:528: [invalid-argument-type] invalid-argument-type: Argument to function `_configure_provider` is incorrect: Expected `dict[Unknown, Unknown]`, found `str | dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[Unknown]] | ... omitted 3 union elements`
environments/agentic_opd_env.py:76: [unresolved-import] unresolved-import: Cannot resolve imported module `pydantic`
environments/tool_call_parsers/kimi_k2_parser.py:18: [unresolved-import] unresolved-import: Cannot resolve imported module `openai.types.chat.chat_completion_message_tool_call`
environments/benchmarks/yc_bench/yc_bench_env.py:663: [unresolved-import] unresolved-import: Cannot resolve imported module `tqdm`
tools/rl_training_tool.py:206: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `str`, found `str | bytes | int | ... omitted 4 union elements`
environments/web_research_env.py:62: [unresolved-import] unresolved-import: Cannot resolve imported module `datasets`
rl_cli.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `fire`
environments/hermes_swe_env/hermes_swe_env.py:47: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.type_definitions`
tools/rl_training_tool.py:229: [invalid-argument-type] invalid-argument-type: Argument to function `module_from_spec` is incorrect: Expected `ModuleSpec`, found `ModuleSpec | None`
tests/run_agent/test_agent_loop_tool_calling.py:66: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.envs.server_handling.server_manager`
hermes_cli/tools_config.py:2469: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `dict[Unknown, Unknown]` and `str | list[dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[Unknown]] | dict[str, str | list[dict[str, str]]]] | list[dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[dict[str, str]]]] | ... omitted 4 union elements`
environments/web_research_env.py:51: [unresolved-import] unresolved-import: Cannot resolve imported module `pydantic`
gateway/platforms/yuanbao.py:2509: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `MessageType`, found `Any | None`
tests/tools/test_tool_call_parsers.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_managed_server_tool_support.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_tts_kittentts.py:6: [unresolved-import] unresolved-import: Cannot resolve imported module `numpy`
environments/benchmarks/yc_bench/yc_bench_env.py:63: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.envs.server_handling.server_manager`
environments/benchmarks/yc_bench/yc_bench_env.py:62: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.envs.base`
tools/rl_training_tool.py:253: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.envs.base`
environments/benchmarks/tblite/tblite_env.py:33: [unresolved-import] unresolved-import: Cannot resolve imported module `pydantic`
tools/rl_training_tool.py:775: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["wandb_run_name"]` and value of type `str` on object of type `list[dict[str, str | int | float]]`
rl_cli.py:239: [invalid-parameter-default] invalid-parameter-default: Default value of type `None` is not assignable to annotated parameter type `str`
environments/benchmarks/terminalbench_2/terminalbench2_env.py:802: [unresolved-import] unresolved-import: Cannot resolve imported module `tqdm`
... and 85 more

Unchanged: 4304 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@amazon-q-developer amazon-q-developer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pull request adds protocol validation to the URL safety module to block non-HTTP(S) schemes like file://, javascript:, and data:. The implementation correctly validates URL schemes and integrates well with the existing SSRF protection framework.

The changes are secure and functional:

  • Protocol validation properly restricts to http/https only
  • Existing SSRF protections (private IP blocking, metadata endpoint blocking) remain intact
  • Error handling and logging are appropriate
  • No breaking changes to the public API

The code is ready to merge.


You can now have the agent implement changes and create commits directly on your pull request's source branch. Simply comment with /q followed by your request in natural language to ask the agent to make changes.


⚠️ This PR contains more than 30 files. Amazon Q is better at reviewing smaller PRs, and may miss issues in larger changesets.

@wiz-inc-0c327c0c81

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 6 Low
Software Management Finding Software Management Findings -
Total 6 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

@hawknewton hawknewton merged commit 1fabb72 into main May 15, 2026
16 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.