Skip to content

fix(skills): use _github_get retry in _get_repo_tree to prevent hangs on network timeout - #2

Open
luxles wants to merge 785 commits into
mainfrom
fix/get-repo-tree-retry
Open

fix(skills): use _github_get retry in _get_repo_tree to prevent hangs on network timeout#2
luxles wants to merge 785 commits into
mainfrom
fix/get-repo-tree-retry

Conversation

@luxles

@luxles luxles commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Bug Description

When the GitHub API is unreachable through a proxy (intermittent SSL/TLS failures, VPS packet loss, or corporate firewall), hermes skills inspect / install / fetch hang indefinitely on ssl.recv() until Ctrl+C.

Reported in NousResearch#41053 (user in a network-restricted environment).

Existing Fixes

PR NousResearch#41195 (open) fixes the parallel_search_sources ThreadPoolExecutor hang in browse/search by changing shutdown(wait=True) to wait=False, cancel_futures=True. That fix is complementary to this one.

PR NousResearch#41811 was closed as duplicate of NousResearch#41195.

This Fix

_get_repo_tree() was the only GitHub API caller in GitHubSource still using bare httpx.get() calls without retry. This PR switches both HTTP calls (repo info + git tree) to the existing _github_get() wrapper.

Key design decisions:

  • max_retries=0 (infinite retry): Retries forever until the request succeeds or the user presses Ctrl+C. The CLI never hangs silently.
  • Progress to stderr: Each retry prints a status line (Fetching owner/repo tree... attempt 3, 4s timeout (Ctrl+C to cancel)) so the user sees activity and knows how to cancel.
  • No interactive prompt: Works in non-interactive contexts (scripts, subagents, cron) since the user always has Ctrl+C.

Changes

  1. tools/skills_hub.py: _github_get gains max_retries=0 (infinite retry) and retry_label for stderr progress; _get_repo_tree uses both.
  2. tests/tools/test_skills_hub.py: Three new tests (transport failure, non-200 status, happy path).

Closes NousResearch#41053

ehz0ah and others added 30 commits June 22, 2026 07:00
Mirror built-in memory writes to external providers only after the native memory tool succeeds and is not staged for approval. Keep OpenViking's built-in memory mirroring add-only, since Hermes native memory entries do not yet have stable OpenViking file URIs for replace/remove.

Add a narrow viking_forget tool for exact user memory file deletion and document the current OpenViking write/delete behavior.
…face

The success/staged gating and op-expansion for mirroring built-in memory
writes to external providers lived in a standalone agent/memory_write_bridge.py
helper called inline from two core call sites (tool_executor.py,
agent_runtime_helpers.py). That left the mirror decision-making in the agent
loop, outside the memory-provider interface.

Fold it into a new MemoryManager.notify_memory_tool_write() entry point: the
loop now hands over the raw tool result + args and a metadata callback, and the
manager decides whether/what to mirror. Both core call sites collapse to a
single call; the orphan module is removed. No MemoryProvider ABC change.

Tests rewritten as behavior tests against the manager method.
…ousResearch#50848)

* chore: re-trigger CI (workflows did not dispatch on prior head)

* fix(delegation): emit high-concurrency cost warning once per process

_get_max_concurrent_children() runs on every get_definitions() schema
rebuild (via _build_top_level_description / _build_tasks_param_description),
not just on actual delegate_task calls. With max_concurrent_children>10 the
cost advisory fired on every turn / agent spawn across every session, spamming
the log even when delegate_task was never used. Gate it behind a module-level
_HIGH_CONCURRENCY_WARNED flag so it warns at most once per process.
The Slack docs document `slack.mention_patterns` as custom wake words that
trigger the bot alongside `@mention`, and the config layer bridges the key into
the Slack adapter's `config.extra` — but the adapter never read it. With
`require_mention` on, a channel message containing a configured wake word (and
no literal `<@BOTUID>`) was silently ignored. Every other adapter that
documents `mention_patterns` (Telegram, DingTalk, Mattermost, WhatsApp,
BlueBubbles, Photon) implements it; Slack was the odd one out.

Add `_slack_mention_patterns()` (compiled, cached; reads `slack.mention_patterns`
as a list/string or `SLACK_MENTION_PATTERNS` as a JSON/CSV/newline list, invalid
regexes warned and skipped) and `_slack_message_matches_mention_patterns()`,
mirroring the existing adapters. Channel mention detection now also triggers on
a wake-word match, so the documented field works as described.

Adds tests for pattern compilation (list/string/env/invalid-regex) and for the
channel-trigger gating with a wake word under require_mention.
…in (NousResearch#50842)

* feat(computer_use): disable cua-driver telemetry by default, add opt-in

cua-driver ships anonymous PostHog usage telemetry ENABLED by default
upstream (fires cua_driver_install / cua_driver_doctor events to
eu.i.posthog.com). Hermes now disables it for our users unless they
explicitly opt in.

- New config key `computer_use.cua_telemetry` (default false) in
  DEFAULT_CONFIG.
- `cua_backend.cua_driver_child_env()` injects
  `CUA_DRIVER_RS_TELEMETRY_ENABLED=0` into the child env when telemetry is
  disabled (the default); leaves the var untouched on opt-in so the driver
  uses its own default. Reads config fail-safe — any error defaults to
  telemetry off.
- Routed every cua-driver spawn site through the policy: MCP backend
  (StdioServerParameters env), `cua_driver_update_check`, doctor's
  health_report Popen, the install.sh/install.ps1 runner, and the
  `--version` / status probes.
- Docs: new Telemetry subsection in computer-use.md (EN).
- Tests: tests/computer_use/test_cua_telemetry.py — default disables,
  explicit-false disables, opt-in leaves var untouched, config-failure
  fails safe, inherited-enabled is overridden off.

Verified live on Linux against the real cua-driver-rs 0.6.0 binary: with
the var=0 the driver reports "telemetry: disabled via
CUA_DRIVER_RS_TELEMETRY_ENABLED" and sends no event; with it unset it logs
"sending event: cua_driver_doctor". 213 computer_use + install tests green.

* fix(dashboard): fold computer_use config category into agent tab

The new computer_use.cua_telemetry key created a single-field dashboard
config category, tripping test_no_single_field_categories (web_server's
invariant that categories with <2 fields must be merged to avoid tab
sprawl). Add computer_use -> agent to _CATEGORY_MERGE, matching the
existing onboarding/telegram single-field folds.
…tant

/simplify-code (LOW, flagged by two reviewers): the source tags 'user' /
'project' / 'bundled' were bare string literals scattered across the discovery
scrub and the two mount-time refuse guards. A typo in any one site (e.g.
'users') would SILENTLY disable a security gate with no error — the exact
failure mode this RCE boundary must not have.

Introduce a shared module-level _NON_BUNDLED_PLUGIN_SOURCES frozenset referenced
by both the discovery scrub and the (now single) mount guard, so the
auto-import policy lives in one place. The two mount guards collapse into one
gate that still emits the distinct per-source operator message via a map (no
loss of guidance). Behavior unchanged: 39 RCE-bypass tests pass, and the
constant is mutation-checked (typo'ing it fails the bypass tests).

Defence-in-depth (discovery scrub + mount refuse) is retained intentionally.
…-dashboard-plugin-rce

fix(security): restrict dashboard plugin backend auto-import to bundled plugins — defense-in-depth (NousResearch#43719)
… off-screen

The popped-out composer is position:fixed, but the chat content wrapper sets
`contain: layout paint`, which makes it a containing block for — and clips —
fixed descendants. Inline, the floating composer was positioned/clipped relative
to the chat column (which shifts with the sidebars), not the viewport, so the
viewport-based bounds clamp from NousResearch#50466 couldn't keep it reachable: users still
lost it off-screen. Portal it to <body> when popped out so fixed positioning and
the clamp finally share the viewport as their reference. Docked stays inline
(it's absolute within the chat column by design).
Replaces the body-portal approach: render ChatBar as a sibling of the
contain:[layout paint] chat wrapper (inside the same runtime boundary) rather
than portaling the floating instance to <body>. The wrapper is a containing
block for — and clips — position:fixed descendants, which is what stranded the
popped-out composer off-screen. As a sibling it anchors to the outer relative
container: docked stays absolute (identical placement), floating resolves
against the viewport. Both states stay mounted, so dock<->float no longer
remounts the editor (the portal toggle did).
…le window

Now that the popped-out composer is fixed to the viewport, clamping against the
window let it slide under a pinned sidebar. Confine it to the thread region
(data-slot="composer-bounds") instead — its rect already excludes a pinned
sidebar and the header — falling back to the full window before it's measured.
This subsumes the old titlebar top-margin (the thread rect starts below the
header).
Re-clamp once more on the next frame after pop-out so layout (sidebar widths,
fonts) has settled, and treat a degenerate pre-layout bounds rect as "unknown"
(fall back to the window) so we never clamp the box into a collapsed area. Net:
anyone who loads in with a stranded position is pulled back on-screen and the
fix is persisted, even if the first measure was premature.
…ixed-portal

fix(desktop): keep floating composer on-screen, scoped to the thread area
…earch#50849)

* chore: re-trigger CI (workflows did not dispatch on prior head)

* feat(skills): add cloudflare-temporary-deploy optional skill

Optional web-development skill teaching the agent to deploy a Worker to a
live workers.dev URL with no Cloudflare account via 'wrangler deploy
--temporary' (Wrangler 4.102.0+). Cloudflare provisions a throwaway,
claimable account valid for 60 minutes — ideal for an autonomous
write->deploy->verify loop with no OAuth/signup hard stop.

- SKILL.md: when/when-not, prereqs (unauth requirement, version floor),
  step-by-step deploy + verify flow, product limits table, pitfalls
  (hidden flag, stale global wrangler, auth-present error, rate limits,
  workers.dev edge cache), verification.
- scripts/parse_deploy_output.py: stdlib-only parser extracting live URL,
  claim URL, account name/state, expiry, deploy status from wrangler output.
- tests/skills/test_cloudflare_temporary_deploy_skill.py: 16 tests incl.
  a real-output regression case.

Verified live end-to-end: temporary account created with no creds,
deployed to a live URL, curl confirmed body, redeploy reused the account.
…ousResearch#50501)

Adds an optional structured completion contract to the standing-goal loop,
adapted from OpenAI Codex's /goal guidance (a durable objective works best
when it names what done means, how to prove it, what not to break, what's in
scope, and when to stop).

A contract has five optional fields — outcome, verification, constraints,
boundaries, stop_when. When set, the continuation prompt tells the agent to
target the verification surface and respect constraints, and the judge marks
the goal done only when the verification criterion is met with concrete
evidence (command result, file excerpt, test output) instead of a loose
"looks done" claim. This tightens the most common /goal failure mode:
premature completion / endless over-continuation on an underspecified goal.

Two ways to set a contract, both backward compatible (bare /goal <text>
behaves exactly as before):
- /goal draft <objective>  — expands plain text into a full contract via the
  goal_judge aux model (cache-safe side call), falls back to a free-form goal
  if the model is unavailable.
- /goal <text> with inline 'field: value' lines (verify:, constraints:,
  boundaries:, stop when:, ...). Plain goals with an incidental colon are not
  mangled — only known field prefixes are pulled out.
- /goal show prints the active contract.

Contracts persist in SessionDB.state_meta alongside the goal (survive /resume),
compose with /subgoal criteria, and old goal rows load unchanged. CLI + every
gateway platform via the shared GoalManager engine; zero new model tools.

Tests: +18 in tests/hermes_cli/test_goals.py (parse/serialize/judge-prompt/
draft/fallback), 73/73 green; 42/42 across the broader goal test surface;
live E2E roundtrip (set -> persist -> reload -> contract-aware prompts) green.
cua-driver 0.6.x removed the standalone screenshot MCP tool, so
capture(mode='vision') hit 'Unknown tool: screenshot' and returned a
0x0 image with no PNG while som/ax (which use get_window_state) still
worked. Route vision through get_window_state(capture_mode='vision').

Salvaged from PR NousResearch#50771; same fix submitted earlier as NousResearch#39262 by
@Tranquil-Flow.
capture(app='screen'|'desktop') now resolves to the OS shell/desktop
window (Windows Progman/WorkerW desktop or Shell_TrayWnd taskbar, macOS
Finder/Dock) so 'show me my screen' and 'click the taskbar' work.
Previously capture() only matched application windows, and the schema
advertised 'or the whole screen' without any code path delivering it.

cua-driver is window-oriented (no virtual-desktop or per-monitor MCP
tool), so a single image still cannot span multiple monitors — the
schema now states this and the no-desktop-window path returns a clear
message instead of silently grabbing the frontmost app.
…ch#31501)

Targeted ``(chat_id, thread_id)`` prune for the
``telegram_dm_topic_bindings`` table — the missing piece for
NousResearch#31501, where the Telegram adapter detects a topic the user
deleted out-of-band but the binding row keeps living in
state.db.  The recovery logic in
``gateway.run._recover_telegram_topic_thread_id`` then steers
every future inbound message back to the dead topic, dropping
tool progress, approvals and replies into the wrong place.

Returns the number of rows deleted; silently no-ops when the
topic-mode tables haven't been migrated yet (read-only / pristine
profile) so the helper is safe to call from a send-fallback
hot path before the schema has run.
…ousResearch#31501)

Both fallback sites that currently log "Thread X not found,
retrying without message_thread_id" now also drop the
``telegram_dm_topic_bindings`` row keyed on
``(chat_id, thread_id)``:

* The streaming send loop (``send`` body) — fires on the
  second failure, after the same-thread one-shot retry confirms
  the thread really is gone (the first attempt is left alone
  because Bot API has been observed to return a transient
  "Thread not found" that recovers on immediate retry).
* The control-message helper ``_send_message_with_thread_fallback``
  (approval prompts, model picker, update prompts) — single-shot
  retry, prune unconditionally on the BadRequest match.

Without this prune, a user who deletes a Telegram DM topic in
the client keeps getting their next inbound message recovered
back to the dead thread by
``_recover_telegram_topic_thread_id`` in ``gateway/run.py``,
which walks the per-user binding list newest-first and treats
the deleted thread as authoritative.  The reproduction in the
bug report is exactly this: tool progress, approvals, activity
messages and replies all land in the wrong place until the user
manually runs DELETE on state.db.

Cleanup is best-effort — we log at INFO when it succeeds, swallow
any exception from the SessionDB call, and the user-facing send
proceeds either way.

Refs NousResearch#31501
…h#31501)

Thirteen tests across four layers:

* ``SessionDB.delete_telegram_topic_binding`` — pin the new
  helper's contract: removes only the (chat_id, thread_id) row
  it was asked about, leaves siblings alone, returns 0 silently
  when the row never existed, and is a no-op on a pristine
  database whose topic-mode tables haven't been migrated yet.
* ``TelegramAdapter._prune_stale_dm_topic_binding`` — the glue
  must drop the binding when ``self._session_store._db``
  exposes the helper, swallow exceptions so a failed cleanup
  never breaks the user-facing send, and refuse to issue a
  DELETE for ``chat_id=None`` / ``thread_id=None`` so a
  bookkeeping miss can't accidentally null-match every row.
* Source-level guards on ``TelegramAdapter.send`` and
  ``_send_message_with_thread_fallback`` — the prune call must
  sit beside the two existing "Thread X not found, retrying
  without message_thread_id" warnings, before the retry runs,
  so a future refactor can't silently drop the cleanup wire.
* End-to-end semantic — once a topic is pruned, the
  ``GatewayRunner._recover_telegram_topic_thread_id`` walk
  steers future inbound messages to the surviving binding
  instead of the dead one.  This is the exact behaviour change
  the bug report's reproduction asks for: no more landings in
  the wrong topic until the operator hand-edits ``state.db``.

Refs NousResearch#31501
Follow-up to NousResearch#31501. When the send-fallback prune removes a chat's
final telegram_dm_topic_bindings row, also flip
telegram_dm_topic_mode.enabled to 0 in the same transaction.

Without this, a user who turns topics off in the Telegram client
(rather than via /topic off) leaves enabled=1 with zero lanes:
_recover_telegram_topic_thread_id keeps treating the chat as
topic-enabled and lobby messages keep hunting for bindings that no
longer exist. Clearing the flag makes recovery fully stand down once
the dead topics are gone.

Adds 3 regression tests covering the last-binding clear, the
multi-binding no-op, and the unmatched-prune no-op.
…usResearch#51005)

Salvages NousResearch#50469 by @libre-7.

_dashboard_local_update_managed_externally() previously blocked every containerized dashboard from the local update API, even when the running install was a bind-mounted git checkout that can be updated with hermes update.

Allow the dashboard updater only for git installs inside containers, while keeping hosted /opt/data, docker, and pip installs managed externally. Pip remains blocked because its apply path mutates the running container filesystem and is not the self-managed checkout case.

Adds regression coverage for docker, git, and pip install-method handling inside containers, and maps the contributor email for release attribution.

Co-authored-by: libre-7 <libre-7@users.noreply.github.com>
…usResearch#45006)

A typed `/model <name>` where `<name>` is declared under `providers.<slug>` or
`custom_providers` — but typed while the current provider is a soft-accepting
one (e.g. `openai-codex`) — stayed on the current provider and was swallowed as
an unknown hidden Codex model, instead of routing to the provider that actually
declares it.

Add configured-provider exact-match detection (`_configured_provider_matches`)
and a new Step d.5 in `switch_model`: if the typed model is declared in
user/custom provider config, route to that provider BEFORE
`detect_provider_for_model()` guesses from static catalogs and BEFORE the
common-path validation lets a soft-accepting current provider swallow the name.

- Matching is exact (case-insensitive) against explicitly-declared model
  collections only (`models`, `model`, `default_model`) — never fuzzy/family.
- Same-provider declarer → keep current provider (canonicalize the id).
- Multiple declarers → fail clearly and ask for `--provider <slug>`.
- Single declarer → route there; for `providers.<slug>` user providers, set
  `explicit_provider` so the credential block resolves base_url/key from config.
- Step e (`detect_provider_for_model`) is gated off when `config_routed`.

The deliberately-supported openai-codex / xai-oauth hidden-model soft-accept
(NousResearch#16172 / NousResearch#19729) is left untouched: when nothing in config matches, detection
is a no-op.

Salvaged from NousResearch#45442 by harjothkhara (authorship preserved).

Tests: tests/hermes_cli/test_model_switch_configured_provider_routing.py
(7 tests). Full model_switch suite: 214 passed.

Fixes NousResearch#45006
…al (NousResearch#51031)

* chore: re-trigger CI (workflows did not dispatch on prior head)

* fix(image/video gen): make schema delivery instruction platform-neutral

The image_generate and video_generate tool schema descriptions hardcoded
a gateway-only delivery instruction ('display it with markdown
![description](url-or-path) and the gateway will deliver it'). That schema
is sent on every platform, so on CLI it directly contradicted the CLI
platform hint ('Do NOT emit MEDIA:/path tags ... state its absolute path
in plain text'), and on messaging platforms it was also wrong about the
mechanism (local file paths are delivered via MEDIA: tags, not markdown
image syntax — markdown ![]() only works for URLs).

The per-platform file-delivery convention is already owned correctly by
the platform hints in prompt_builder.py. The tool schema now just
describes the result shape (URL or absolute path in the image/video field)
and defers 'how to deliver' to the active platform's guidance.

Provider/model injection already works via _build_dynamic_image_schema()
(the 'Active backend: <provider> · model: <model>' line); no change there.
…pstream installer

`hermes computer-use install` refused to install on Linux, Windows, and
macOS x86_64 because the pre-install asset probe was hitting the wrong
GitHub endpoint AND duplicating tag-resolution logic the upstream
installer already does correctly.

`_check_cua_driver_asset_for_arch()` queried
`https://api.github.com/repos/trycua/cua/releases/latest`. On trycua/cua:

- cua-driver-rs releases (the binary the installer fetches) are marked
  **prerelease** on every cut. GitHub's `/releases/latest` explicitly
  skips prereleases.
- The Python package releases (`cua-agent`, `cua-computer`, `cua-train`)
  are non-prerelease and end up as the "latest" instead.

Live API check today:

  $ curl -sf https://api.github.com/repos/trycua/cua/releases/latest \
      | jq '{tag:.tag_name, asset_count: (.assets|length)}'
  { "tag": "agent-v0.8.3", "asset_count": 0 }

The probe sees zero assets, prints "Latest CUA release has no Linux
x86_64 asset", and skips install on every Linux / Windows / macOS-x86_64
host — even though the cua-driver-rs-v0.6.0 release ships 19 binary
assets covering all those platforms.

Filtering `/releases?per_page=N` for the `cua-driver-rs-v*` prefix
fixes the bug, but it duplicates tag-resolution logic the upstream
`_install-rust.sh` already does correctly via `CUA_DRIVER_RS_BAKED_VERSION`
(auto-baked by CD on every release, with a `/releases?per_page=N` API
fallback for dev checkouts). The right answer is to trust that
contract instead of mirroring it in Python where it can drift.

Two paths get the same outcome without the probe:

1. **Fresh install**: run `install.sh` directly. It has the baked
   release tag, fetches the right asset, and errors with a clear
   message on missing-arch downloads. No preflight needed.
2. **Upgrade path**: `cua_driver_update_check()` (separately added)
   shells `cua-driver check-update --json` against the installed
   binary, which returns the canonical update answer from the same
   source the installer uses.

- `hermes_cli/tools_config.py`: delete `_check_cua_driver_asset_for_arch`
  and its two call sites in `install_cua_driver`. Replace with an
  inline comment near the top of the module explaining the rationale.
- `tests/hermes_cli/test_install_cua_driver.py`: drop the
  `TestCheckCuaDriverAssetForArch` block. Add `TestArchProbeRemoval`
  with three regressions:

  - `test_probe_function_is_gone` — asserts the deleted helpers stay
    deleted.
  - `test_fresh_install_does_not_call_github_api` — asserts the
    install path doesn't hit GitHub directly from Python anymore.
  - `test_upgrade_with_binary_does_not_call_github_api_directly` —
    same for the upgrade path.

All 9 `test_install_cua_driver` tests pass.

Reported by @teknium1 while testing on a headed Ubuntu host.
f-trycua's NousResearch#50855 test file predated the cross-platform PR (NousResearch#50552) and
reintroduced two stale tests asserting Linux is unsupported
(test_*_non_macos_*, patching platform.system="Linux" and expecting a
no-op/warn). Linux + Windows are supported now, so install proceeds on
those platforms. Restore main's cross-platform-correct versions:
test_*_on_unsupported_platform_* using FreeBSD as the genuinely
unsupported case.
OutThisLife and others added 24 commits June 24, 2026 18:36
The 8-minute stream-silence watchdog only removed a stuck session from
$workingSessionIds (the sidebar dot). The composer's busy state lives in
the session-state cache and was never cleared, so a hung or looping turn
that never delivered its terminal event — including an old session
re-opened while the backend still reports it "running" — stayed wedged on
"Thinking" / Stop indefinitely.

Have the watchdog notify subscribers when it force-clears a session, and
subscribe from the session-state cache to also drop that session's
busy/awaiting/needsInput flags. updateSessionState re-syncs $busy when the
healed session is the one on screen, so the composer recovers instead of
spinning forever.

Frontend-only safety net; doesn't touch the turn lifecycle. The backend
root (a stale in-memory session["running"] surviving a dead turn thread
and re-arming busy on every resume) is a separate follow-up.
…ents-status

fix(desktop): make Agents indicator match the Spawn-tree panel
fix(desktop): wire Ctrl+B voice, declutter voice settings, stop endless TTS hang
…fline

fix(desktop): give the gateway reconnect loop an escape hatch
…op-guard

fix(desktop): let the session watchdog heal a stuck "looping" turn
…uthz

A hosted instance fronted by the Team Gateway connector dropped EVERY relay
message as "Unauthorized user" and the agent never replied — despite the
message routing correctly through the connector to the instance.

Root cause: gateway authorization (_is_user_authorized) had no notion of
upstream-enforced authz. Platform.RELAY matches no {PLATFORM}_ALLOWED_USERS
allowlist and isn't in the HA/WEBHOOK always-authorized set, so a relay user
with no env allowlist configured hit the default-deny ("No user allowlists
configured. All unauthorized users will be denied."). The message was received,
then silently denied before reaching the agent.

This is incorrect for relay: the connector authenticates the gateway's WS with
a per-instance secret and performs owner-only author-binding resolution BEFORE
delivering. A message only reaches this gateway because the connector resolved
it to THIS instance's bound user (user_instance_binding), keyed on the author id
the connector OBSERVED off the event — never a gateway claim. The authorization
decision is already made by a trusted, authenticated upstream; there is no local
RELAY_ALLOWED_USERS allowlist to consult, and default-denying for its absence is
the bug.

Fix: add a generic BasePlatformAdapter.authorization_is_upstream capability
(default False) that the relay adapter overrides to True, plus a dedicated
trusted branch in _is_user_authorized that honors it. This is delegation to a
trusted upstream, NOT a fail-open: it fires only for an adapter that explicitly
declares the flag; every direct network-exposed adapter leaves it False and the
env-allowlist default-deny (SECURITY.md §2.6) is unchanged. Distinct from
enforces_own_access_policy, which mirrors a LOCAL config-driven allowlist —
this delegates to an authenticated upstream's decision.

Tests: behavior contract that the base defaults False, the relay adapter
declares True, a relay user (group + DM) is authorized with no env allowlist,
and crucially a non-upstream adapter with no allowlist still default-denies
(guards against the fix becoming a blanket fail-open). 6 new tests; relay +
authz + config-policy suites green (134 + 90).

Found via live staging debug of the Discord self-serve onboarding flow.
Ship the final pet-generation UX polish (provider picker behavior, step-2 cancel flow, banner integration, and visual consistency) and make saturated-chroma background removal C-op driven so hatch processing no longer hammers the machine during long runs.
The desktop installer clones with `--depth 1`, so a public install's local
history often shares no merge-base with the freshly fetched origin tip. In
that state `git rev-list HEAD..origin/<branch> --count` enumerates the
entire remote ancestry and returns a meaningless huge number, surfacing as
e.g. "v0.17.0 (+12104)" in the update indicator (NousResearch#51922).

The official-SSH branch of checkUpdates() already sidesteps this by reporting
a binary up-to-date check (`behind: currentSha === targetSha ? 0 : 1`), and
hermes_cli/banner.py guards the identical class for the CLI banner. The
passive desktop count path was the one place the shallow guard was missing.

Detect shallow + no-merge-base up front and fall back to the same SHA-based
binary check; full clones (developers / Docker dev images) keep the exact
count path unchanged. The resolution logic lives in a pure update-count.cjs
helper so it is unit-testable without booting Electron.
checkUpdates() ran `git rev-list HEAD..origin/<branch> --count`
unconditionally in the parallel probe batch, even on the shallow +
no-merge-base path where resolveBehindCount() ignores the result and
falls back to a SHA compare. In the NousResearch#51922 failure mode that count walks
the entire remote ancestry (thousands of commits), so the work was pure
latency on every update check for the exact case the fix targets.

Split the probes into two phases: resolve --is-shallow-repository and
merge-base first, then run rev-list --count only when shouldCountCommits
says the number is meaningful (full clone, or shallow-with-merge-base).
The shallow/no-merge-base SHA fallback is preserved unchanged.
…ok hung

On macOS, the desktop updater's stage 1 (hermes update --gateway) ends by
restarting running gateways. launchd_restart() SIGTERMs the gateway and
silently waits up to agent.restart_drain_timeout (default 180s) for the
drain; the manual profile-gateway loop waits its drain budget per gateway
the same way. Neither path prints anything before the wait, so the desktop
updater's live output goes dead for minutes right after '✓ Update
complete!' — users read it as a hung update and force-kill their gateway
processes to make it move (NousResearch#44515). The systemd branch already announces
its drain ('draining (up to Ns)...'); launchd and the manual loop did not.

Print the stop/drain (with PID and budget) before the wait in both paths,
mirroring the systemd branch, and assert the message in the existing
launchd drain test.

Fixes NousResearch#44515
…rofile

restartGateway, getActionStatus, getStatus, updateHermes and
checkHermesUpdate all hit window.hermesDesktop.api WITHOUT spreading
profileScoped() — unlike their siblings (getModelInfo, setModelAssignment,
grantComputerUsePermissions). _apiProfile tracks the active gateway
profile, and the Electron proxy uses request.profile to pick which pooled
/ remote backend serves the call.

So for a multi-profile or global-remote user, the System-panel "Restart
gateway" (and its status poll, plus Update / status reads) targeted the
primary/default backend instead of the one they're on: the restart hit
the wrong gateway and the poll never saw the action → it looked like
restart silently failed. Single-profile users are unaffected
(profileScoped() returns {} when no profile is active).

Add ...profileScoped() to the five backend-action helpers so they follow
the active profile like the rest of the API surface.
Two ways the update overlay read as stuck even though the update was
streaming progress underneath:

- In-app (macOS/Linux) UpdatesOverlay: runStreamedUpdate forwards every
  stdout line as a progress event with percent: null, and ingestProgress
  wrote that straight through — clobbering the milestone percents (10/60)
  so the bar fell back to indeterminate on every log line. Keep the last
  percent when a line carries null.

- Staged install/update overlay: the bar is completedCount / totalCount,
  which counts only *finished* stages, so a long first stage pinned it at
  "0 of 2" / 0% until the stage ended. Count the running stage as half a
  unit so the bar advances during the stage (the per-stage spinner already
  shows which step is live).

Both are display-only; no stage/event semantics change. (The Windows
hermes-setup Tauri progress UI in apps/bootstrap-installer has the same
counter-only-on-completion logic — parity follow-up.)
Remove cute/chibi-biased wording from base draft variations and explicitly preserve the requested mood across base and row prompts so scary, eerie, or other non-cute concepts are honored while keeping sprite constraints.
PR NousResearch#52151 hardened the runtime-status liveness check to trust a readable
live process command line over stale gateway_state.json argv, so a recycled
PID now owned by an s6 supervisor no longer counts as a running gateway.

That fix is correct but incomplete for the reported symptom: the web
dashboard showed a named profile's gateway green while
`hermes -p <name> gateway status` showed it stopped. Two further issues:

1. Cross-profile PID reuse. In per-profile Docker supervision, one profile's
   stale `gateway_state.json` can record a PID the OS later recycled onto a
   DIFFERENT profile's live gateway. That PID's command line still
   `looks_like_gateway`, so the dead profile was reported running. The
   recorded argv has its `-p <name>` selector stripped in-process by
   `_apply_profile_override`, so it cannot disambiguate; the live `/proc`
   cmdline still carries it. `get_runtime_status_running_pid` now accepts an
   `expected_home` and validates the live command line belongs to THAT
   profile (mirroring `hermes_cli.gateway._matches_current_profile`, the
   logic the CLI scan path already uses — which is why the CLI was correct).
   `_check_gateway_running` passes the enumerated profile dir.

2. The existing regression test `test_gateway_running_check_falls_back_to_
   runtime_state` used the live pytest PID with a gateway-shaped record; once
   the live cmdline became authoritative it no longer looked like a gateway.
   Updated to mock the live cmdline to the real separate-process scenario it
   describes.

The active-profile path (`get_running_pid`) is intentionally left unscoped:
it is lock-verified and any live gateway cmdline is acceptable there. Multiplex
mode is unaffected — `running` state is only ever written to a gateway's own
home, never a secondary served profile's.

Adds coverage for: cross-profile PID reuse (named + default), matching
profile cmdline (`-p`, `--profile`, explicit HERMES_HOME=), the bare default
gateway, and the unreadable-cmdline cross-platform fallback. Each new
cross-profile assertion fails without the profile scope and passes with it.

Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
…in-announce

fix(update): announce gateway drain waits so desktop updates don't look hung
…start-profile

fix(desktop): route gateway restart / status / update to the active profile
…date-steps

fix(desktop): stop the update overlay looking frozen while it works
Wire the sparkle generate button's cancel action to the same discard/reset path as step-2 cancel so abort semantics are consistent and always return to step 1 while retaining the prompt input.
Stabilize the long-running-tool heartbeat test by patching stale thresholds inside the test and asserting the heartbeat exceeds the idle ceiling, which preserves intent while removing scheduler-sensitive assumptions that flake in CI.
…allow-update-count

fix(desktop): don't report a bogus update count for a shallow checkout
Pet generation: frame-perfect hatch flow, backend picker, CPU-safe chroma, and CI-hardening
…-progress-visibility

fix(desktop): surface update progress lines
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

🔎 Lint report: fix/get-repo-tree-retry 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: 11246 on HEAD, 10687 on base (🆕 +559)

🆕 New issues (622):

Rule Count
unresolved-import 214
unresolved-attribute 195
invalid-argument-type 73
invalid-assignment 33
not-subscriptable 33
unsupported-operator 27
invalid-method-override 20
unresolved-reference 6
call-non-callable 5
invalid-return-type 5
not-iterable 3
no-matching-overload 3
unused-type-ignore-comment 3
invalid-raise 1
invalid-parameter-default 1
First entries
plugins/platforms/feishu/adapter.py:4603: [unresolved-attribute] unresolved-attribute: Attribute `Application` is not defined on `None` in union `Unknown | None`
tests/cli/test_cli_pet_pane.py:23: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
tests/gateway/relay/test_relay_going_idle.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/email/adapter.py:839: [invalid-method-override] invalid-method-override: Invalid override of method `send_document`: Definition is incompatible with `BasePlatformAdapter.send_document`
tests/tools/test_signal_media.py:186: [invalid-assignment] invalid-assignment: Object of type `((...) -> Awaitable[dict[Unknown, Unknown]]) | None` is not assignable to attribute `standalone_sender_fn` on type `PlatformEntry | None`
tests/tools/test_async_delegation.py:421: [invalid-argument-type] invalid-argument-type: Argument to function `AIAgent._dispatch_delegate_task` is incorrect: Expected `AIAgent`, found `_FakeAgent`
tests/run_agent/test_session_source.py:1: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/restart.py:16: [invalid-argument-type] invalid-argument-type: Argument to constructor `float.__new__` is incorrect: Expected `str | Buffer | SupportsFloat | SupportsIndex`, found `Unknown | int | str | ... omitted 16 union elements`
tests/agent/test_pet_generate.py:258: [unresolved-attribute] unresolved-attribute: Attribute `generated` is not defined on `None` in union `InstalledPet | None`
tests/hermes_cli/test_aux_config.py:54: [unresolved-attribute] unresolved-attribute: Attribute `keys` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int`, `float` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 34 union elements`
plugins/platforms/dingtalk/adapter.py:1546: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/plugins/model_providers/test_ollama_cloud_profile.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_openrouter_response_cache.py:232: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
tests/tools/test_mcp_elicitation.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_setup_noninteractive.py:122: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
plugins/memory/mem0/_setup.py:550: [unresolved-import] unresolved-import: Cannot resolve imported module `psycopg2`
plugins/platforms/dingtalk/adapter.py:84: [unresolved-import] unresolved-import: Cannot resolve imported module `alibabacloud_tea_openapi`
plugins/platforms/email/adapter.py:295: [invalid-argument-type] invalid-argument-type: Argument to function `cache_document_from_bytes` is incorrect: Expected `bytes`, found `(Message[str, str] & ~AlwaysFalsy) | (bytes & ~AlwaysFalsy) | (Any & ~AlwaysFalsy)`
plugins/platforms/slack/adapter.py:94: [unresolved-import] unresolved-import: Cannot resolve imported module `slack_sdk.web.async_client`
plugins/platforms/raft/adapter.py:476: [unresolved-attribute] unresolved-attribute: Attribute `Application` is not defined on `None` in union `Unknown | None`
tests/agent/test_compression_rotation_state.py:56: [unresolved-attribute] unresolved-attribute: Unresolved attribute `context_compressor` on type `AIAgent`
tests/plugins/memory/test_mem0_setup.py:31: [unresolved-attribute] unresolved-attribute: Unresolved attribute `config` on type `ModuleType`
tests/hermes_cli/test_provider_parity.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
plugins/platforms/dingtalk/adapter.py:1448: [unresolved-attribute] unresolved-attribute: Attribute `data` is not defined on `None` in union `Unknown | None`
tests/agent/test_curator.py:1105: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["curator"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 34 union elements`
... and 597 more

✅ Fixed issues (270):

Rule Count
unresolved-attribute 108
unresolved-import 69
invalid-argument-type 32
invalid-method-override 18
invalid-assignment 15
unsupported-operator 14
invalid-return-type 4
call-non-callable 3
unresolved-reference 2
invalid-raise 1
no-matching-overload 1
unresolved-global 1
unused-type-ignore-comment 1
not-subscriptable 1
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`
gateway/platforms/dingtalk.py:73: [unresolved-import] unresolved-import: Cannot resolve imported module `alibabacloud_dingtalk.robot_1_0`
gateway/platforms/feishu.py:5073: [unresolved-attribute] unresolved-attribute: Attribute `Client` is not defined on `None` in union `Unknown | None`
tests/tools/test_web_providers.py:218: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["search_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 33 union elements`
gateway/platforms/wecom_callback.py:139: [unresolved-attribute] unresolved-attribute: Attribute `AppRunner` is not defined on `None` in union `Unknown | None`
gateway/platforms/telegram.py:6014: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_last_chunk_len` on type `MessageEvent`
tools/delegate_tool.py:2308: [invalid-argument-type] invalid-argument-type: Argument to function `dispatch_async_delegation` is incorrect: Expected `list[str] | None`, found `(Any & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | list[str] | None`
agent/background_review.py:527: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `str`, found `(Any & ~AlwaysFalsy & ~Literal["codex_app_server"]) | None | Literal["codex_responses"]`
gateway/platforms/telegram.py:4168: [unresolved-attribute] unresolved-attribute: Attribute `HTML` is not defined on `None` in union `Unknown | None`
gateway/platforms/whatsapp.py:347: [invalid-argument-type] invalid-argument-type: Argument to constructor `Path.__new__` is incorrect: Expected `str | PathLike[str]`, found `str | None`
gateway/platforms/feishu.py:4606: [unresolved-attribute] unresolved-attribute: Attribute `TCPSite` is not defined on `None` in union `Unknown | None`
gateway/platforms/telegram.py:2096: [unresolved-attribute] unresolved-attribute: Attribute `PHOTO` is not defined on `None` in union `Unknown | None`
tools/delegate_tool.py:2470: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> dict[str, str | None | list[str]], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[dict[str, str | None | list[str]]]]` cannot be called with key of type `str` on object of type `list[dict[str, str | None | list[str]]]`
gateway/platforms/matrix.py:4077: [unresolved-attribute] unresolved-attribute: Attribute `group` is not defined on `None` in union `Match[str] | None`
agent/google_oauth.py:235: [unresolved-attribute] unresolved-attribute: Module `msvcrt` has no member `locking`
gateway/platforms/slack.py:1964: [invalid-method-override] invalid-method-override: Invalid override of method `send_document`: Definition is incompatible with `BasePlatformAdapter.send_document`
tools/delegate_tool.py:2470: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> dict[str, str | None | list[str]], (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[dict[str, str | None | list[str]]]]` cannot be called with key of type `None` on object of type `list[dict[str, str | None | list[str]]]`
gateway/platforms/matrix.py:1891: [invalid-method-override] invalid-method-override: Invalid override of method `send_video`: Definition is incompatible with `BasePlatformAdapter.send_video`
gateway/platforms/telegram.py:4988: [unresolved-attribute] unresolved-attribute: Attribute `CHANNEL` is not defined on `None` in union `Unknown | None`
gateway/platforms/telegram.py:2096: [unresolved-attribute] unresolved-attribute: Attribute `VOICE` is not defined on `None` in union `Unknown | None`
gateway/platforms/dingtalk.py:1192: [unresolved-attribute] unresolved-attribute: Attribute `StreamingUpdateRequest` is not defined on `None` in union `Unknown | None`
gateway/platforms/telegram.py:2092: [unresolved-attribute] unresolved-attribute: Attribute `LOCATION` is not defined on `None` in union `Unknown | None`
hermes_cli/gateway.py:5158: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def qr_scan_for_bot_info(*, timeout_seconds: int = 300) -> dict[str, str] | None`
gateway/platforms/telegram.py:2387: [unresolved-import] unresolved-import: Cannot resolve imported module `telegram.error`
plugins/memory/mem0/__init__.py:175: [unresolved-import] unresolved-import: Cannot resolve imported module `mem0`
... and 245 more

Unchanged: 5316 pre-existing issues carried over.

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

@luxles
luxles force-pushed the fix/get-repo-tree-retry branch 2 times, most recently from 90fc038 to f260431 Compare June 25, 2026 05:57
… on network timeout

_get_repo_tree was the only GitHub API caller in GitHubSource still using
bare httpx.get() calls without retry.  When the GitHub API is unreachable
through a proxy (intermittent SSL/TLS failures, VPS packet loss, or
corporate firewall), the request hung on ssl.recv() until Ctrl+C.

Changes:
1. _github_get: support max_retries=0 (infinite retry) with while True loop
2. _get_repo_tree: switch both HTTP calls to self._github_get(max_retries=0)
3. do_inspect (CLI layer): background heartbeat with increasing intervals
   (0s -> 5s -> 15s -> 30s -> 60s -> every 30s), Ctrl+C to cancel
4. Tests: transport failure, non-200 status, happy path

Unlike PR NousResearch#41195 (parallel_search_sources ThreadPoolExecutor hang for
browse/search), this addresses the transport-level _get_repo_tree hang
that affects inspect/fetch/install.

Closes NousResearch#41053
@luxles
luxles force-pushed the fix/get-repo-tree-retry branch from f260431 to 590bd7c Compare June 25, 2026 07:00
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.

fix: skills hub hangs on network timeout + hermes update intermittent session-resume misrouting