Merge upstream v2026.6.19 (Hermes v0.17.0 "Reach Release") - #1
Conversation
…504) (NousResearch#47674) * fix(dashboard): recover the Chat tab when the agent session ends (NS-504) In the dashboard Chat tab, when the agent process exits — the user types `/exit`, or starts a new session that ends the current PTY child — the `/api/pty` WebSocket closes with a normal code (not one of the 4401/4403/4404/4408/1011 rejection codes the server emits). The frontend handled only those rejection codes; the normal-exit fallback just printed "[session ended]" into the dead terminal and stopped, with `wsRef` nulled and no respawn path. The only recovery was a full page refresh — exactly the beta report ("typing /exit breaks functionality, no way to restart without refreshing"; "starting a new session completely breaks the agent"). On a clean/normal close the Chat tab now flips `sessionEnded` and renders an in-place "Start new session" overlay (mirroring ChatSidebar's existing reconnect affordance). Clicking it bumps a `reconnectNonce` that is a dependency of the connect effect, so the effect tears down and re-runs, spawning a fresh PTY in place — no page refresh. `onopen` clears the flag so a successful reconnect dismisses the overlay. An explicit button (rather than auto-respawn) is deliberate: if the agent is crash-looping, auto-respawn would hide the failure and spin; the user stays in control. Verified against a live uvicorn `/api/pty` socket: a child that exits closes with a non-rejection code (client sees close_code None / 1000-class), which is precisely the branch that now sets sessionEnded=true. web typecheck + vite build clean. Reported via beta (NS-504). * docs(assets): add NS-504 chat session recovery infographic
) (NousResearch#48109) Weak open models (mimo, nemotron-class) that see tool-call XML/JSON sitting in file contents or tool output get primed and emit their own structured tool calls mimicking the payload — usually with an empty/whitespace name. Those calls can't be fuzzy-repaired toward a real tool, so the dispatch loop returns an error and the model retries. Before this fix, every empty-name error dumped the full tool catalog back to the model, which fed the priming loop more names to mimic and inflated context 3-4x across the retry budget. A blank/whitespace-only tool name now gets a terse anti-priming error that tells the model in-context tool-call syntax is DATA, with no catalog dump. A genuinely-wrong-but-nonempty name (a real typo) still gets the full catalog so the model can self-correct. Not a sandbox/auth boundary issue: Hermes never parses tool-call text from content into executable calls (structured tool_calls only; the lone text->call parser is the Copilot ACP transport and it also rejects empty names). The reporter's own debug dump confirms the injection never executed. Behavior-contract test added: empty-name -> terse error, no catalog; nonempty unknown -> catalog preserved. Exercised end-to-end via run_conversation against an in-process mock provider.
NousResearch#48122) The desktop self-update runs `hermes update` then `hermes desktop --build-only`, and only relaunches if the rebuild returns 0. The first `--build-only` can exit nonzero on a still-settling post-update tree or a network-blocked Electron fetch that the installer's self-heal repaired mid-run — so both updaters (the Tauri setup binary and the in-app POSIX path) bailed before the relaunch step. The update landed but the app never restarted; a manual launch worked because the heal had completed. Retry `--build-only` once in both paths before failing, mirroring the retry-once `hermes update` already does (and the CLI `hermes update`'s own desktop rebuild). A second run builds clean off the healed dist and is a near-no-op when the first actually succeeded (content-hash stamp). - update.rs: retry stage 2; add rebuild_needs_retry() + test - main.cjs: retry via new update-rebuild.cjs helper (behavior-tested)
…omposer context
- model_metadata: grok-composer-2.5-fast → 262144 (OAuth slug not in /v1/models)
- codex transport: inject native {"type":"web_search"} for is_xai_responses;
drop client web_search to avoid duplicate-name 400s
- codex adapter: do not treat in-progress server-side *_call items as incomplete
- tests: adapter, transport build_kwargs, model_metadata, oauth recovery
…tx to 200k Salvage corrections on top of @XVVH's NousResearch#44341: - Make native web_search injection a 1:1 swap for an already-present client web_search function, NOT an additive grant. The original unconditionally appended {"type":"web_search"} on every is_xai_responses turn with any tools, force-enabling Grok server-side search even when the user never enabled the web toolset (bypassing Hermes web-provider config + tool-trace plumbing). Now gated on a client web_search actually being present. - Reconcile grok-composer context to 200000 (merged in NousResearch#47908) rather than 262144; 200k is xAI's published usable context window for Composer 2.5, 262144 is the /v1/responses input+output budget. - Update tests to match scoped behavior + add a no-web-toolset guard test. - AUTHOR_MAP entry for NousResearch#44341 salvage. Incomplete-guard (server-side *_call items at in_progress no longer flip has_incomplete_items) and preflight built-in-tool allowlist kept as-is.
…der (NS-505) (NousResearch#47555) * fix(docker): supervised gateway uses --replace to take over stale holder Inside the s6 container image the per-profile gateway service rendered a bare `hermes gateway run` (no --replace). When a gateway is started OUTSIDE s6 — a stray shell `hermes gateway run`, an agent action, or the Open WebUI helper (scripts/setup_open_webui.sh) — it grabs the per-HERMES_HOME PID lock first. The supervised slot then execs the bare `gateway run`, hits the "Another gateway instance is already running" guard, exits non-zero, and s6 restarts it: a restart loop that floods the log every ~12s and never binds. The container looks up but the gateway is permanently down, and dashboard-only users (no shell) cannot recover. Render the supervised run script as `gateway run --replace` so s6 is authoritative for its slot: it reaps the stale holder via the hardened takeover path (takeover marker + SIGTERM->SIGKILL-with-confirmation + scoped-lock cleanup in gateway/run.py) and binds. This matches the systemd service path, which already builds its argv with --replace (_build_gateway_argv / 'nohup hermes gateway run --replace'), and the intent already documented in _maybe_redirect_run_to_s6_supervision. The existing HERMES_S6_SUPERVISED_CHILD sentinel still prevents the run->start->run redirect recursion. Each profile is scoped to its own HERMES_HOME and s6 guarantees one supervised instance per slot, so there is no legitimate supervised sibling for --replace to clobber. Reported via beta (NS-505): gateway.log showed PID 17907 'running (manual process)' with the guard error repeating every ~12s on v2026.6.5. Adds a regression test asserting every gateway-run exec line in the rendered script (default + named profile, both privilege branches) carries --replace, and updates the existing render-script assertion. * fix(ci): remove stray .venv symlink committed into repo The PR's commit accidentally tracked a .venv symlink pointing at the developer's local venv (mode 120000 -> /home/ben/nous/hermes-agent/.venv). The CI test/e2e/build jobs run `uv venv` to create .venv and failed with `failed to create directory .venv: File exists (os error 17)` because the checkout already contained the symlink. All test shards aborted in <15s during setup, before any test ran. Untrack the symlink and add a bare `.venv` entry to .gitignore (the existing `.venv/` rule only matches a directory, so a symlink slipped through).
…eiver + enroll CLI (NousResearch#48147) * feat(relay): authenticate the connector⇄gateway WS channel The relay gateway may be customer-managed and internet-exposed, so the connector⇄gateway channel is itself authenticated (distinct from the platform crypto the relay path sheds). Add gateway/relay/auth.py — a Python port of the connector's HMAC token + delivery-signature schemes (relayAuthToken.ts / deliverySigning.ts), verified byte-for-byte against the connector's compiled TypeScript via cross-language test vectors. Present an Authorization bearer on the /relay WS upgrade keyed by the per-gateway secret (resolved from GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET in env or config). The connector rejects an unauthenticated/invalid/ revoked upgrade with close 4401. * feat(relay): signed-HTTP inbound delivery receiver The connector delivers normalized inbound events to a tenant's gateway over a signed HTTP POST, not the outbound /relay WS: the connector instance owning a platform socket is generally not the instance a given gateway dialed out to, so inbound targets a tenant endpoint that may load-balance across gateway instances. Add gateway/relay/inbound_receiver.py — verifies x-relay-signature / x-relay-timestamp over the EXACT raw request bytes (re-serializing would break the HMAC: JS JSON.stringify is compact, Python json.dumps spaces) against the per-tenant delivery key verify list within a 300s replay window, then dispatches messages to handle_message and interrupts to the interrupt handler. Wire it into the adapter lifecycle (start in connect() when a delivery key + bind port are configured, tear down in disconnect(); a purely-outbound dev gateway runs without it). Refine test_relay_sheds_crypto to distinguish PLATFORM crypto (Discord ed25519, Twilio/WeCom HMAC — still shed) from the connector⇄gateway CHANNEL auth (intended): auth.py / inbound_receiver.py are exempt from the platform-symbol scan but still banned from importing platform-crypto modules, plus a positive guard that auth.py uses only stdlib hmac/hashlib. * feat(relay): hermes gateway enroll CLI Add the gateway half of zero-touch enrollment. `hermes gateway enroll` resolves a fresh Nous Portal access token (the tenant-proving identity), POSTs {enrollmentToken, gatewayId} to the connector's /relay/enroll, and persists GATEWAY_RELAY_ID / GATEWAY_RELAY_SECRET / GATEWAY_RELAY_DELIVERY_KEY to ~/.hermes/.env. The per-gateway secret authenticates the WS upgrade; the per-tenant delivery key verifies signed inbound deliveries. Refuses under is_managed() (hosted installs get the secret stamped in by the orchestrator). Added as an 'enroll' subcommand on the existing gateway subparser — not a new top-level command. * docs(relay): inbound is signed HTTP, not WS; document channel auth Fix the stale contract: §3/§5 said inbound rode the WS socket (single- instance only, predates the multi-instance socket-ownership + channel-auth model). Inbound + connector→gateway interrupt are signed HTTP POSTs to the tenant endpoint. Add §6.1 documenting the two channel-auth schemes (per- gateway WS-upgrade secret, per-tenant inbound delivery key) and how they differ from the platform crypto the relay path sheds. * test(relay): update build_gateway_parser callers for cmd_gateway_enroll The enroll subcommand added cmd_gateway_enroll as a required keyword-only arg to build_gateway_parser, but two existing parser-extraction tests still called it with only cmd_gateway/cmd_proxy — failing CI with TypeError. Thread the new handler through both call sites and add a test asserting `gateway enroll` dispatches to cmd_gateway_enroll with its flags parsed.
* fix(approval): honor glob command allowlist entries * fix(approval): guard allowlist globs from shell chaining
…provider refactor
Phase 1 of the pluggable cron-scheduler refactor (Axis B — the trigger).
No call-site changes; this phase only makes the abstraction exist + tested
in isolation.
Task 1.1: cron/scheduler_provider.py — the EXPERIMENTAL CronScheduler ABC.
Required surface is name + start; is_available()/stop() carry safe defaults.
is_available has a no-network invariant. Docstring marks it experimental
until the Chronos provider (Phase 4) validates the shape.
Task 1.2: InProcessCronScheduler wraps the historical 60s ticker loop, calling
cron.scheduler.tick(sync=False) exactly as the raw ticker does. Uses
stop_event.wait(interval) for responsive stop (both raw tickers already do).
Tests: ABC-is-abstract, default-is_available, the InProcess loop drives tick
and stops, stop() no-op, and test_abc_growth_stays_additive (the forward-compat
guard: required abstractmethods must stay exactly {name, start}, so the three
Phase-4 hooks land as NON-abstract additions).
tick() internals in cron/scheduler.py are byte-unchanged (only new file added).
Phase 0 characterization tests still green. Full tests/cron/: 445 passed.
Phase 2 of the pluggable cron-scheduler refactor. Still no call-site changes;
this wires up provider SELECTION with a hard safety net.
Task 2.1: cron.provider config key (hermes_cli/config.py), empty = built-in.
Additive key — deep-merge picks it up into existing configs with no version
bump (verified: load_config() yields the key on a pre-existing config.yaml).
Task 2.2: plugins/cron/__init__.py — discovery machinery cloned near-verbatim
from plugins/memory/__init__.py, retargeted at CronScheduler /
register_cron_scheduler. Bundled (plugins/cron/<name>/) + user
(/plugins/<name>/) dirs, bundled wins collisions. The built-in is
NOT discovered here — it's core, so the fallback can't be removed.
Task 2.3: resolve_cron_scheduler() in cron/scheduler_provider.py — reads
cron.provider and ALWAYS degrades to built-in (missing / unavailable / load
error / typo all fall back with a warning). cron can never be left without a
trigger.
Deviation from plan: the plan's resolver snippet used cfg_get("cron.provider")
(dotted-string form). The real cfg_get signature is cfg_get(cfg, *keys,
default=) — corrected to cfg_get(load_config(), "cron", "provider", default=""),
matching plugins/memory/__init__.py:349. Tests monkeypatch load_config (not
cfg_get) so the real traversal runs.
Tests: default key empty, discovery returns list, unknown load returns None,
and the four resolver paths (empty→builtin, no-section→builtin,
unknown→builtin, unavailable→builtin, available→used). Full tests/cron/: 453
passed; config suite green (additive key, no migration break).
…_HOME (NousResearch#48188) The install method (docker/git/pip/...) describes the *running binary*, but detect_install_method() read it from $HERMES_HOME/.install_method — a shared DATA directory. The Docker docs deliberately bind-mount $HERMES_HOME (~/.hermes:/opt/data) so config/sessions/memory persist and can be shared with a host-side Desktop/CLI install. When a containerized gateway and a host install share one $HERMES_HOME, the home-scoped stamp is a single slot describing two installs: the published image stamps 'docker' on every boot, the host install then reads 'docker' and the in-app updater refuses to run 'hermes update' ("doesn't apply inside the Docker container"). Reinstalling the Desktop app from the DMG doesn't help because the contaminated stamp is re-read every time. Fix (option 1 — code-scoped stamp): - detect_install_method() reads <install tree>/.install_method first (next to the running code, immune to the shared data dir). It falls back to the legacy $HERMES_HOME stamp for back-compat, but IGNORES a 'docker' home stamp when not actually containerized — so already-poisoned shared homes self-heal. - stamp_install_method() writes the code-scoped stamp. - install.sh stamps $INSTALL_DIR instead of $HERMES_HOME. - Dockerfile bakes 'docker' into /opt/hermes/.install_method at build time (inside the immutable block); stage2-hook.sh no longer writes the home stamp and proactively removes a stale 'docker' one to heal existing shared homes. Genuine containers still resolve to 'docker' (baked stamp, or legacy home stamp honored when containerized). Unstamped installs in generic containers still fall through to git/pip (preserves the NousResearch#34397 fix).
Phase 3 — rebind both ticker call sites to resolve_cron_scheduler(). Default
(built-in) path is byte-identical; Phase 0 characterization tests + the full
gateway suite (6919) stay green.
Task 3.1: split gateway/run.py _start_cron_ticker into:
- _start_gateway_housekeeping() — the gateway-only chores (channel-dir
refresh, image/doc cache cleanup, paste sweep, curator poll), now on their
own loop/thread, independent of which cron provider is active.
- _start_cron_ticker() — kept as a DEPRECATED shim that runs only the
built-in InProcessCronScheduler().start(), preserving the symbol for
hermes_cli/debug.py and the Phase 0 characterization test.
Task 3.2: start_gateway() resolves the provider and runs provider.start() in
the 'cron-scheduler' thread, plus a second 'gateway-housekeeping' thread;
teardown sets the shared cron_stop, calls provider.stop(), joins both.
Task 3.3: desktop _start_desktop_cron_ticker() swapped its inline tick loop for
resolve_cron_scheduler().start() (no adapters/loop — desktop has none).
The provider owns ONLY the cron tick (so an external scale-to-zero provider
with no 60s loop fits); gateway housekeeping is decoupled from the cron
trigger. Both threads share cron_stop.
Verified: full tests/cron/ (453) + full tests/gateway/ (6919) green. Manual
gateway smoke (Task 3.4) is operator-run, pending.
Phase 3.5. cron-internals.md gateway-integration section now describes the pluggable trigger (resolve_cron_scheduler, built-in default, plugins/cron discovery, the never-without-a-trigger fallback, and the trigger-vs-execution split). cli-commands.md notes cron.provider near the hermes cron entry.
Phase 4A. Factor tick's per-job closure (_process_job: execute → save → deliver → mark) into a module-level run_one_job(job, *, adapters, loop, verbose) so the external Chronos provider's fire_due (Phase 4D) reuses the IDENTICAL body — no duplicated correctness. tick's _process_job is now a thin wrapper calling run_one_job; the pool/in-flight-guard/contextvars dispatch logic is unchanged. run_one_job fires ONE given job; it does NOT decide due-ness, claim, or compute next_run (tick advances next_run_at under the file lock; an external provider claims via the store CAS in Phase 4C). Pure refactor, no behavior change. TDD: test_run_one_job.py characterizes the sequence through tick() first (test_tick_process_job_sequence, passed pre-extraction), then unit-tests the helper directly: success sequence, [SILENT]→skip delivery, empty-response soft failure (NousResearch#8585), failed-job-still-delivers, exception→mark-failed. Verified: tests/cron/ 459 passed (was 453 + 6 new); tick behavior unchanged.
…concile)
Phase 4B. Three NON-abstract hooks on the CronScheduler ABC, all with
built-in-safe defaults so the built-in inherits them without overriding and
test_abc_growth_stays_additive stays green (required surface still {name,
start}):
- on_jobs_changed(): post-mutation reconcile hook. Built-in no-op.
- fire_due(job_id): claim the job via the store CAS (claim_job_for_fire,
Phase 4C) then run it through the shared run_one_job (Phase 4A). Returns
False if the claim is lost or the job vanished (repeat-N exhausted between
arm and fire). The inbound webhook (Phase 4E) routes here.
- reconcile(): converge the external registry toward jobs.json. Built-in no-op.
fire_due imports claim_job_for_fire/get_job/run_one_job INSIDE the method, so
this commits cleanly before Phase 4C lands claim_job_for_fire (import-time is
unaffected; tests monkeypatch it with raising=False).
Tests: required-surface-unchanged guard, built-in inherits no-op defaults, and
fire_due's three paths (claim+run, lost-claim→no-run, missing-job→no-run).
tests/cron/ green (20 in test_scheduler_provider.py).
Phase 4C. claim_job_for_fire(job_id, *, claim_ttl_seconds=300) in cron/jobs.py:
under the existing _jobs_lock() file lock, claim a job for a single external
fire so that across N gateway replicas exactly ONE wins. Single-machine
deployments always win (unaffected).
Semantics:
- missing / disabled / paused job → False.
- a fresh fire_claim (younger than claim_ttl_seconds) already present → False
(someone else holds it). Stale claim (crashed winner) → overwrite, so a job
is never wedged forever.
- on win: stamp fire_claim={at, by:_machine_id()}; for recurring (cron/interval)
advance next_run_at (mirrors advance_next_run's at-most-once bump so a stale
re-delivery can't re-fire); one-shots keep next_run_at but the fresh claim
blocks a duplicate retry for the same fire.
- mark_job_run now clears fire_claim on completion so a re-armed recurring job
is claimable again next fire.
_machine_id() (HERMES_MACHINE_ID env, else hostname:pid) is attribution-only;
correctness is the file lock + fresh-claim check, not the id.
This is consumed by CronScheduler.fire_due (Phase 4B). tick is untouched — it
still uses advance_next_run, so the built-in single-machine path is unaffected.
Tests (real store, temp HERMES_HOME): claim-once-then-block + next_run advance,
one-shot no-double-claim, unknown→False, paused→False, stale-claim reclaimable,
mark_job_run clears the claim (recurring re-claimable). tests/cron/ 470 passed.
Phase 4D. The first non-default CronScheduler: plugins/cron/chronos/. Inert
unless cron.provider=chronos; resolve_cron_scheduler falls back to the built-in
if unavailable, so cron never loses its trigger.
Files:
- chronos/__init__.py — ChronosCronScheduler + register(ctx).
* is_available(): config-only, NO network (portal_url + callback_url + a
stored Nous access token via get_provider_auth_state). Returns False →
resolver falls back to built-in.
* start(): reconcile() then RETURN — no blocking loop, no 60s wake (DQ-1:
this is what makes scale-to-zero real; the machine wakes only on a
NAS→agent fire).
* _arm_one_shot(job): POST NAS provision {job_id, fire_at, agent_callback_url,
dedup_key=job_id:fire_at}. Agent owns the time → sub-minute fires survive
(no scheduler 1-minute floor).
* reconcile(): converge NAS arms toward jobs.json — arm missing/changed-time,
cancel orphaned, skip paused. Cold process rebuilds from jobs.json +
idempotent dedup_key.
* on_jobs_changed(): reconcile (re-arm/cancel the affected one-shot).
* fire_due(): ABC default (CAS claim + run_one_job) THEN re-arm the next
one-shot. Job gone (one-shot done / repeat-N exhausted) → no re-arm.
- chronos/_nas_client.py — thin HTTP wrapper for provision/cancel/list using
the agent's existing refresh-aware Nous token (resolve_nous_access_token).
Names no scheduler vendor; holds no scheduler creds.
- chronos/plugin.yaml — discovery metadata.
INVARIANT: zero "qstash"/"upstash" hits in plugins/cron, gateway, hermes_cli,
website/docs — the external scheduler is a NAS-internal detail, never named
agent-side.
Tests (13, all NAS mocked, zero network): is_available off-without-config +
on-with-config + makes-no-network; arm payload incl. sub-minute + noop without
next_run; reconcile arms-all / cancels-orphan / skips-paused / skips-already-
armed; fire_due re-arms next / no re-arm when job gone / no re-arm when claim
lost.
…hronos) Phase 4E (E.1 + E.2). The inbound side of Chronos: NAS POSTs the agent when a one-shot fires; the agent verifies a NAS-minted JWT and runs the job. E.1 — plugins/cron/chronos/verify.py: - verify_nas_fire_token(token, expected_audience, jwks_or_key, issuer): verifies signature against the NAS JWKS (RS/ES family; symmetric rejected), aud == this agent, exp/nbf, iss, and purpose == "cron_fire" (so a general agent JWT can't be replayed against the fire endpoint). Returns claims or None; never raises. Crypto delegated to PyJWT[crypto] (already a declared dep) — no hand-rolled JWT, no new dependency. No key configured → refuse (never unsigned-decode a security boundary). - get_fire_verifier(): pluggable indirection so the DQ-4 escape hatch (direct per-job cron-key) can swap in with no handler change. E.2 — gateway/platforms/api_server.py: - POST /api/cron/fire (registered only when _CRON_AVAILABLE). Authenticated by the NAS-JWT via get_fire_verifier() — NOT API_SERVER_KEY (NAS holds no API key; this is the only inbound that triggers remote job execution, so it gets its own purpose-scoped check). Verifier args come from cron.chronos.* config. 401 on bad/missing/forged token. 400 on missing job_id. On success: 202 + fire_due runs in the background (so a long agent turn never trips NAS's HTTP timeout); the store CAS claim inside fire_due de-dupes a scheduler retry. Tests: - test_chronos_verify (11): REAL RS256 signing — valid→claims, wrong-aud, missing/wrong purpose, expired, wrong-iss, tampered-signature (attacker key), no-key-refuse, empty-token, JWKS-URL key resolution, get_fire_verifier. - test_cron_fire_webhook (5): valid→202+fire, invalid→401+no-fire, missing token→401, missing job_id→400, and fire path does NOT require API_SERVER_KEY. api_server regression suites (214) green. E.3 (NAS endpoints) is a separate cross-repo PR; the wire contract lands next (docs/chronos-managed-cron-contract.md).
…AS contract
Phase 4F (F.1 + F.2 + F.3, agent side). F.4 is the operator-run live smoke
(needs a NAS deployment); recorded in the PR, not code.
F.1 — on_jobs_changed wiring:
- cron/scheduler.py: _notify_provider_jobs_changed() — resolve the active
provider, call on_jobs_changed(), swallow errors. Lives in scheduler.py (not
jobs.py) so the store stays free of provider imports (no import cycle).
- Wired at the consumer surfaces AFTER a successful mutation: the cronjob model
tool (tools/cronjob_tools.py, create/update/remove/pause/resume) — which the
`hermes cron` CLI also routes through — and the REST handlers
(gateway/platforms/api_server.py, same five). Built-in's no-op default = zero
behavior change on the default path. Sleeping-agent direct jobs.json writes
(no tool/CLI/REST) are covered by reconcile-on-wake in start().
F.2 — config: cron.chronos.{portal_url,callback_url,expected_audience,
nas_jwks_url}. All non-secret; the agent holds no scheduler creds and the
outbound provision call reuses the existing Nous token (no token key). Additive
deep-merge key, no version literal.
F.3 — docs:
- docs/chronos-managed-cron-contract.md: authoritative agent↔NAS wire contract
(the three agent-cron endpoints + inbound /api/cron/fire + the 3-hop trust
model + at-most-once/re-arm semantics). This is what the NAS-side agent builds
against.
- cron-internals.md: "Managed cron (Chronos) for scale-to-zero" section.
- cli-commands.md: cron.provider accepts chronos + the cron.chronos.* keys.
- User docs name no scheduler vendor (QStash is a NAS-internal detail).
INVARIANT re-verified: zero qstash/upstash hits across plugins/cron, gateway,
hermes_cli, tools, website/docs (the one remaining repo hit is an unrelated
Context7 MCP comment in tools/mcp_tool.py).
Tests: test_jobs_changed_notify (5) — notify calls provider hook, swallows
errors, built-in harmless, tool create/remove notify. Full cron + chronos +
webhook + config + api_server_jobs suites green (504 in the cron+chronos+webhook
run).
…e) (NousResearch#48242) The gateway half of relay Phase 3. On a MANAGED boot with relay configured and no secret pinned, the runtime self-provisions its relay credentials IN-PROCESS: resolve the agent's own Nous access token (resolve_nous_access_token) -> POST the connector's /relay/provision asserting its own endpoint + route keys -> set GATEWAY_RELAY_ID/SECRET/DELIVERY_KEY into os.environ so the immediately- following register_relay_adapter() reads them and dials out authenticated. No human, no enrollment token, no disk write — the creds live only in process memory (save_env_value refuses under managed anyway, and keeping the secret off any volume is the stronger posture). Stateless: process-env creds don't survive a restart, so a managed container re-provisions every boot; the connector's rotation window covers a still-connected prior instance. An explicitly-pinned GATEWAY_RELAY_SECRET is respected (skip). Self-hosted is unchanged: humans keep using `hermes gateway enroll`. Endpoint provenance is gateway-asserted (GATEWAY_RELAY_ENDPOINT + GATEWAY_RELAY_ROUTE_KEYS, env or gateway.relay_* config) — uniform code path whether the operator sets it (self-hosted) or NAS stamps it (hosted, the only case NAS knows the public URL). Both absent -> outbound-only provisioning (credentials, no inbound routes). The connector scopes the asserted endpoint to the verified tenant, so it stays within the security model. - gateway/relay/__init__.py: relay_endpoint(), relay_route_keys(), _provision_url(), _post_provision(), self_provision_if_managed() (never raises — a provision failure logs and boots without relay auth). - gateway/run.py: call self_provision_if_managed() immediately before register_relay_adapter() in the startup path. Tests: 12 unit (trigger logic, respect-pinned-secret, in-process env wiring, endpoint+routes vs outbound-only, fail-soft on token/connector failure); mutation-checked (drop is_managed guard / pinned-secret guard -> tests fail). Cross-repo live E2E driver lands on the connector side (depends on this). EXPERIMENTAL: relay auth scheme may change until >=2 Class-1 platforms validate.
…state (NS-501) (NousResearch#48243) Importing a backup wrote every file from the zip over the target home wholesale. On a hosted instance this clobbered gateway_state.json with the source machine's last recorded run/desired state — driving the container-boot reconciler (container_boot._read_desired_state, which only auto-starts a gateway whose state is "running") off stale/foreign state and leaving the gateway stuck "starting", disconnected from the Nous portal. Add _IMPORT_SKIP_NAMES (gateway_state.json, gateway.pid, cron.pid, gateway.lock, processes.json) and skip them by basename in run_import, so both the root profile and named profiles preserve the target's own runtime state. This mirrors what container_boot._STALE_RUNTIME_FILES already sweeps on every container boot, and protects against older backups that predate the backup-side exclusions. The import summary reports which files were preserved. This is the second half of NS-501 (filed separately as NS-508): the upload 502 was fixed in NousResearch#47663; this fixes the import-breaks-the-instance half.
…SON (NS-501) (NousResearch#47663) * fix(dashboard): stream file uploads via multipart instead of base64 JSON The dashboard file manager uploaded files (including backup/restore zip archives) by reading them client-side with FileReader.readAsDataURL and POSTing a base64 data URL inside a JSON body to /api/files/upload. For a large backup this (a) inflates the payload ~33%, (b) buffers the whole file plus its decoded copy in memory, and (c) reliably trips an upstream proxy body-size/timeout limit, surfacing as a 502 with the upload appearing to hang indefinitely (NS-501). Dashboard-only hosted users have no shell fallback to place the archive, so backup restore was unusable. Add a streaming multipart endpoint POST /api/files/upload-stream (UploadFile + Form) that reads the request body in 1 MiB chunks straight to a sibling temp file, enforces the existing 100 MB size cap as it streams (413 on overflow, before buffering the whole file), and atomically renames into place so a partial/aborted/over-limit upload never clobbers an existing file. The frontend api.uploadFile now sends multipart/form-data (raw bytes, no base64, browser-set boundary) and FilesPage passes the File object directly; the dead readAsDataUrl helper is removed. The legacy base64 JSON endpoint stays for backward compat. FastAPI's UploadFile/Form require python-multipart, which is NOT pulled in by fastapi itself, so it is added to the base deps, the [web] extra, and the tool.dashboard lazy-install set (kept in sync). Validated: 5 new endpoint tests (roundtrip, multi-chunk >1 MiB, over-limit 413 without clobbering + no temp-file leak, overwrite=false conflict, forced-root traversal containment); existing base64 tests still pass; web typecheck + vite build clean; and a real uvicorn server E2E (5 MB multipart upload -> HTTP 200 in 0.21s, exact byte match) plus a 30 MB TestClient roundtrip confirm constant-memory streaming end to end. Reported via beta (NS-501). * build(deps): regenerate uv.lock for python-multipart (NS-501) CI ran uv lock --check / uv sync --locked which failed because the python-multipart dependency add was not reflected in uv.lock. Regenerate the lockfile (resolves to 0.0.20, matching the [web] extra pin) after merging current main.
) Resolves conflicts from the OpenViking churn that merged after NousResearch#32445 was opened (NousResearch#48042/NousResearch#47662 session-switch + write hardening, NousResearch#47311/NousResearch#47973): - plugins/memory/openviking/__init__.py: keep both __init__ field groups (the PR's _runtime_start_* alongside main's _prefetch_threads/_shutting_down). - tests/plugins/memory/test_openviking_provider.py: keep BOTH the PR's new setup-validation tests and main's session-switch/concurrency tests (disjoint additions to the same region). Two fixes layered while reconciling (contributor work otherwise preserved): - Restore the merged tenant-header contract (NousResearch#22414/NousResearch#21232). The PR had changed _VikingClient defaults to '' and made empty account/user OMIT the tenant headers; main's contract is that empty falls back to 'default' and the X-OpenViking-Account/User headers are ALWAYS sent (ROOT API keys need them). Reverted the constructor to 'account or os.environ.get(..., "default")' and updated the two PR tests that asserted the omit-when-empty behavior. - Close a secret-file TOCTOU in the setup writers. _write_env_vars and _write_ovcli_config wrote the api_key/root_api_key file and chmod 0600 AFTERWARD, leaving a world-readable window on newly-created files. Added _precreate_secret_file() to create with 0600 before any secret bytes land.
…python-multipart (NS-501) Follow-up to NousResearch#47663 (streaming multipart upload), fixing two issues that landed with it. 1. Temp file leaked on client disconnect. The streaming upload endpoint's except chain caught only HTTPException / PermissionError / OSError — all Exception subclasses. asyncio.CancelledError, raised when a browser aborts a large upload mid-stream (the exact NS-501 scenario), is a BaseException, so it bypassed every except clause and reached a finally that only closed the file handle and never unlinked the temp file. Every aborted large upload orphaned a partial `.{name}.*.upload` file (up to ~100 MB) in the target directory. Cleanup now lives in finally, keyed on a `renamed` success flag, so the temp file is removed on every non-success exit including BaseException paths. Added test_stream_upload_cleans_temp_on_cancellation, which fails on the pre-fix code (leaks the temp file) and passes with the fix. 2. python-multipart pinned to ==0.0.27 instead of ==0.0.20. The package was already resolved at 0.0.27 transitively (via daytona) before NousResearch#47663; the explicit ==0.0.20 pin in the [web] extra and the tool.dashboard lazy-install set downgraded it. Bumped both to ==0.0.27 and regenerated with `uv lock`, keeping the lockfile coherent. The base dependency stays >=0.0.9,<1.
…ch#48261) PR infographics are decorative visual hooks for a PR body, not repo artifacts. The established convention (commit 5772e63, "chore: drop in-repo infographic/ directory; keep PR-body URLs only", NousResearch#30854) is to hotlink an externally-hosted image so GitHub camo-proxies it inline, leaving zero binary footprint in the tree. Two such assets had been committed anyway and are referenced nowhere in the codebase: - docs/assets/ns504-chat-session-reconnect.png (1024-equiv, NS-504 PR infographic, added in NousResearch#47674 alongside the ChatPage.tsx fix) - infographic/kanban-db-corruption-defense/infographic.png (re-added a directory NousResearch#30854 had explicitly removed, in NousResearch#30952) Both are unreferenced decorative infographics, so removing them has no effect on docs, website, or app builds. Removing the latter also clears the stray top-level infographic/ directory that NousResearch#30854 had retired. These blobs remain in history (the commits that introduced them are already on main and bundled with real code, so they can't be dropped); this just removes them from the working tree going forward.
feat(memory): improve OpenViking setup UX (salvage NousResearch#32445)
…dead constants Follow-up cleanup on the OpenViking setup path merged in NousResearch#48262: - _write_ovcli_config now uses utils.atomic_json_write(path, data, mode=0o600) instead of the local _precreate_secret_file + write_text + chmod sequence. The shared helper (already used by honcho/mem0/supermemory/hindsight) writes via temp-file + fchmod(0600) + fsync + os.replace, so the ovcli.conf is written atomically (no half-written secret file on crash) and with no chmod-after-write TOCTOU window. _precreate_secret_file stays for the .env writer path. - Remove dead _DEFAULT_ACCOUNT/_DEFAULT_USER constants (0 references; the empty->'default' tenant fallback lives in the _VikingClient constructor). Tests: tests/plugins/memory/test_openviking_provider.py + test_memory_setup.py + openviking_plugin/test_openviking.py -> 130 passed; ruff clean.
…mic-json-write refactor(openviking): reuse atomic_json_write for ovcli config; drop dead constants
… feedback Add a shared runGatewayRestart() (store/system-actions.ts) and wire it to a new Cmd+K "Restart gateway" action. While a restart is in flight the statusbar "Gateway" item swaps its icon for the TUI glyph spinner and reads "restarting…", returning to its real state on completion — driven by a $gatewayRestarting atom, not a transient toast or the generic "Agents running" counter. The helper owns its error handling so fire-and-forget callers can't leak an unhandled rejection; only a failure toasts.
… toasts
The "setup saved" and "platform enabled/disabled" toasts told users their
change needs a gateway restart but left it a separate hunt. Attach a "Restart
gateway" action (the shared runGatewayRestart), and reword the copy to state
the pending consequence ("...takes effect after a gateway restart") now that
the button carries the verb. Updated all 4 locales.
The global body { user-select: none } left log surfaces unselectable. Opt them
back in via the existing data-selectable-text convention — at the shared
LogView primitive (boot-failure + bootstrap install overlays) plus Command
Center recent logs, toolset post-setup output, notification detail, and
subagent stream/file lines.
… restarts in the statusbar, make logs selectable (NousResearch#49094) * fix(desktop): rename "Restart messaging" -> "Restart gateway" The Command Center control restarts the whole messaging gateway, yet was labelled "Restart messaging" while the status line above it reads "Messaging gateway running/stopped". Rename the i18n key to match what it does, across all 4 locales. * feat(desktop): restart the gateway from Cmd+K, with statusbar spinner feedback Add a shared runGatewayRestart() (store/system-actions.ts) and wire it to a new Cmd+K "Restart gateway" action. While a restart is in flight the statusbar "Gateway" item swaps its icon for the TUI glyph spinner and reads "restarting…", returning to its real state on completion — driven by a $gatewayRestarting atom, not a transient toast or the generic "Agents running" counter. The helper owns its error handling so fire-and-forget callers can't leak an unhandled rejection; only a failure toasts. * fix(desktop): offer a Restart gateway action on messaging save/toggle toasts The "setup saved" and "platform enabled/disabled" toasts told users their change needs a gateway restart but left it a separate hunt. Attach a "Restart gateway" action (the shared runGatewayRestart), and reword the copy to state the pending consequence ("...takes effect after a gateway restart") now that the button carries the verb. Updated all 4 locales. * fix(desktop): make rendered logs selectable so they can be copied The global body { user-select: none } left log surfaces unselectable. Opt them back in via the existing data-selectable-text convention — at the shared LogView primitive (boot-failure + bootstrap install overlays) plus Command Center recent logs, toolset post-setup output, notification detail, and subagent stream/file lines.
…es (NousResearch#49113) Resolves the 2 npm audit advisories (1 high, 1 moderate), both from transitive undici: - undici 6.26.0 -> 6.27.0 (high: TLS bypass / header injection / response queue poisoning class, via node-gyp + ui-tui) - jsdom's undici 7.27.2 -> 7.28.0 (moderate, via jsdom test dep) Both are in-range bumps (no --force). Lockfile also reconciled two pre-existing manifest drifts during the install: dompurify 3.4.10 -> 3.4.11 (in-range patch) and the web workspace's already-declared vitest ^4.1.5 devDep. No package.json changes. npm audit reports 0 vulnerabilities in root, ui-tui, and apps/desktop after.
…'t duplicate (NousResearch#49120) The classic CLI status bar could appear twice after a horizontal terminal resize — two bars at two widths with two different elapsed readings. Root cause: prompt_toolkit's Application._on_resize() calls renderer.erase(), which does cursor_up(_cursor_pos.y) + erase_down() using the _cursor_pos.y cached from the LAST render at the OLD width (renderer.py:745). On a column shrink the terminal reflows the already-painted full-width chrome into extra physical rows, so the cached y undershoots: cursor_up doesn't climb past the reflowed rows and erase_down leaves the old bar stranded ABOVE the live origin. The next paint stacks a fresh bar below it. The existing post-resize suppression hides the NEW bar for ~0.35s but never erases the already-reflowed OLD one, so the ghost survives the whole window. Ctrl+L / /redraw clears it, confirming a viewport wipe is the fix. Fix: on a WIDTH change, _recover_after_resize now routes through the same recovery as Ctrl+L — _clear_prompt_toolkit_screen(rebuild_scrollback=False) (CSI 2J, visible viewport only) + _replay_output_history() — BEFORE delegating to prompt_toolkit's resize. Banner-safe: 2J never touches scrollback history (that's CSI 3J, which we don't send here), so the startup banner is preserved. Rows-only resizes skip the clear (no reflow → no ghost) to avoid an extra repaint. Tracks _last_resize_width to distinguish the two. Tests: replace the now-obsolete 'never clears on resize' assertion with two tests — rows-only resize delegates without clearing; width change clears the viewport + replays and never wipes scrollback.
…ousResearch#49141) The web dashboard only showed a read-only "Reasoning" capability badge with no way to set the effort level — unlike the desktop app, which has an effort radio in its composer model menu. This adds a picker so the two surfaces reach parity. - ReasoningPicker: a Select rendered in the chat sidebar, gated on the effective model's supports_reasoning capability (from /api/model/info). Reads/writes agent.reasoning_effort via the existing config REST endpoints (read-modify-write, the dashboard's single-key save pattern), so the value lands in the config the agent boots a fresh chat from. Options mirror the desktop: Off/Minimal/Low/Medium/High/Max. - ChatSidebar: capture supports_reasoning from the model-info fetch and render the picker; on change, show the same 'apply on /new or reload' notice the model switch uses. - reasoning-effort.ts: DOM-free helpers (normalizeEffort + options) so the node-env vitest harness can cover the resolution logic, plus tests.
…sResearch#48013) The image-too-large reactive shrink (try_shrink_image_parts_in_messages) conflated two independent constraints: it always rejected a resize whose re-encoded bytes were >= the original, even when the shrink was driven by a PIXEL-DIMENSION cap (Anthropic many-image 2000px) rather than the byte budget. Downscaled screenshot PNGs routinely re-encode LARGER in bytes, so the dimension-correct result was discarded and the image left oversized -> the provider re-rejected on retry and the session wedged forever. Fix: track which constraint triggered the shrink (bytes vs dimension) and gate the accept on the SAME axis. * dimension path: accept the result as long as it is now within max_dimension, regardless of byte size (verify via Pillow; fall back to the byte gate only when the re-encode can't be decoded). * bytes path: still require bytes to shrink, but ALSO re-check the per-side cap when it's active — _resize_image_for_vision returns a best-effort, possibly over-cap blob when it exhausts its halving budget on a very-high-aspect image, so a byte-shrink alone can leave it over the dimension cap and re-brick on retry. Extend the unshrinkable-oversized guard to the pixel axis so a partial shrink doesn't burn the one-shot retry. Single shared agent path -> fixes CLI, TUI, and gateway alike. Adds a real-Pillow runnable proof (repro_48013_image_shrink_brick.py) that reproduces the issue's per-image table (bricks 3/5 before, passes 5/5 after) plus unit invariants for the dimension and bytes accept/reject paths, partial-progress accounting, and the bytes-path still-over-cap regression surfaced by adversarial review. Closes NousResearch#48013
…rch#49134) Sets the Telegram bot's short description (the line under its name) to "Online" on gateway connect and "Offline" on clean disconnect, gated behind extra.status_indicator (off by default). Telegram bots have no presence/online dot — that's a user-account feature the Bot API doesn't expose for bots. The short description is the closest available surface, so this gives users a way to tell whether the gateway is up from the bot's profile. - New extra.status_indicator flag (+ status_online/status_offline text overrides), read in __init__ via config.extra — no config-schema change. - _set_status_indicator() helper: best-effort, swallows API errors so it never blocks connect/disconnect; truncates to Telegram's 120-char cap. - Wired Online after _mark_connected(), Offline at top of disconnect() while the bot HTTP client is still alive. - 9 unit tests + Telegram docs section. Requested by @ilTrumpista, cc @Teknium.
Cron no_agent and pre-check scripts ran with the full gateway/agent environment, allowing scripts under HERMES_HOME/scripts/ to read provider credentials. Apply _sanitize_subprocess_env like terminal and MCP paths (SECURITY.md section 2.3). Add regression test asserting blocklisted provider vars are absent in the child process.
Wires support for the MCP `elicitation/create` request (Python SDK 1.11+) so MCP servers can ask the user to confirm sensitive operations mid-tool-call (payment authorization, OAuth confirmation, etc.) instead of failing closed or requiring out-of-band biometrics. Behavior: - `tools/mcp_tool.py` adds `ElicitationHandler`, attached per server task and passed to `ClientSession` as `elicitation_callback`. Form-mode requests route through the existing approval system; URL-mode requests decline cleanly (out of scope for this pass). - `tools/approval.py` adds `request_elicitation_consent()`, which dispatches to whichever surface owns the active session — `_await_gateway_decision` for Telegram / Slack / etc. (so the approval prompt lands on the right platform), `prompt_dangerous_approval` for CLI / TUI. Fails closed on timeout, missing notify_cb, or exception. - The MCP tool wrapper snapshots `contextvars.copy_context()` into `MCPServerTask._pending_call_context` before each `session.call_tool` and clears it after. The recv-loop task that dispatches incoming `elicitation/create` requests does not inherit the agent task's contextvars (HERMES_SESSION_PLATFORM and friends), so without the bridge `_is_gateway_approval_context()` returns False on every gateway session and the elicitation falls through to a CLI prompt that has no TTY → fail-closed decline. The handler now reads the snapshot via its `owner` back-reference and replays it through `Context.copy().run(...)` so attribution survives the task hop. Tests (`tests/tools/test_mcp_elicitation.py`): - form-mode accept / decline / cancel - URL-mode declined without prompting - exception in approval system → decline - timeout in approval → cancel - context-bridge regression tests (replay observed in consent call, missing-context fallback, multiple-replay safety, owner with cleared `_pending_call_context`) Verified end-to-end against pay's MCP server on macOS: agent message arrives via Telegram, agent calls `mcp_pay_curl` against a paid endpoint, pay returns 402, ElicitationHandler routes the approval prompt back to the originating Telegram chat, user replies in TG, the curl tool signs and completes. Platforms tested: macOS 14 (darwin/arm64). No Unix-only syscalls introduced; Windows footgun checker passes on the touched files.
next(iter(frozenset)) picked a different blocklist var each run (PYTHONHASHSEED-dependent), hurting reproducibility. sorted()[0] keeps the invariant-style assertion (any real blocklisted var) while making failures reproducible. Follow-up to salvaged PR NousResearch#49207.
…way) MCP servers that connect after the agent's one-time tool snapshot were invisible for the whole session. Two root causes, fixed together: 1. The startup discovery wait was a flat 0.75s. HTTP/OAuth servers commonly take 2-6s on a cold connect, so they missed the window and their tools never entered the agent's snapshot. `thread.join(timeout)` already returns the instant discovery completes, so raising the bound costs ~0s for the common case (no MCP / fast servers) and only ever blocks for a genuinely-pending server, capped so a dead server can't freeze startup. The bound is now configurable via `mcp_discovery_timeout` (config.yaml, default 5.0s). 2. Three call sites duplicated the agent tool-snapshot rebuild (the TUI `reload.mcp` RPC, the gateway reload, and the TUI late-binding refresh thread), and the late-refresh detected changes by tool COUNT — missing an equal-size add/remove swap. Consolidated into one shared `tools.mcp_tool.refresh_agent_mcp_tools(agent)` helper that diffs by tool NAME, mutates the agent under a lock (thread-safe), and respects the agent's own enabled/disabled toolsets. The late-binding refresh keeps its pre-first-turn cache-safety guard: it never rebuilds the tool list once a turn has started, so the cached prompt prefix is never invalidated mid-conversation. Tests: new tests/tools/test_refresh_agent_mcp_tools.py covers the name-based diff, in-place mutation, agent-scoped filtering, thread safety, and the config-driven discovery bound (incl. instant-return when nothing is pending). 75 passed across the touched areas.
…binding) A slow MCP server (HTTP/OAuth, 2-6s cold connect) that finishes connecting after the agent's one-time tool snapshot was uncallable for the rest of the session. The merged pre-first-turn late-refresh only helps during the dead air before the user's first keystroke; once a turn starts it bails to protect the prompt cache, so a user who types before the server connects never gets the tools without a manual /reload-mcp. Refresh the snapshot in the per-turn prologue (build_turn_context), before this turn's first API call assembles tools=. This is cache-safe by construction: the refresh only ever extends a fresh request prefix at a turn boundary, never mutates the cached prefix of an in-flight turn. So late tools become callable on the user's NEXT turn automatically, with no /reload-mcp and no cache cost. - tools/mcp_tool.py: has_registered_mcp_tools() — cheap guard so sessions with no MCP servers (the common case) skip the rebuild entirely. - agent/turn_context.py: call the shared refresh_agent_mcp_tools() helper at the top of the prologue when MCP servers are registered. - tests: 3 contract tests through the real build_turn_context (adds late tool; skipped when no servers; no snapshot churn when unchanged). .hermes/plans/: SPEC + PLAN documenting the root cause, the cache-safety constraint, and why the existing fixes (NousResearch#48403/NousResearch#41630/NousResearch#42802) don't close it.
…ces) Consolidated findings from three independent reviewers (Codex, Claude Code, a Hermes subagent w/ the hermes-agent-dev skill): - BLOCKING: refresh_agent_mcp_tools rebuilt only the registry subset, silently dropping post-build-injected memory-provider (mem0/honcho/…) and context- engine (lcm_*) tools on every refresh. Now additive-preserving: re-applies the same injectors agent_init uses, staged on locals and published atomically. - Re-injection now honors the NousResearch#5544 enabled_toolsets gate for context-engine tools, so a restricted-toolset platform can't get lcm_* leaked back in. - Atomic read-diff-publish under one lock: the returned `added` set and the (tools, valid_tool_names) pair are consistent even under concurrent callers (no half-swap, no TOCTOU). - background_review fork opts out (_skip_mcp_refresh) so its byte-identical tools[] cache parity with the parent is preserved. - CLI /reload-mcp routed through the shared helper (was a 4th divergent copy with the same clobber bug + missing disabled_toolsets). - Explicit reloads (TUI RPC + CLI) pass enabled_override so a server the user just enabled in config this session is picked up; automatic paths reuse the agent's build-time selection. - mcp_discovery_timeout default 5.0 -> 1.5s: correctness now comes from the between-turns refresh, so the startup wait is only a small turn-1 UX bump rather than a heavy dead-server latency penalty. - has_registered_mcp_tools checks registered TOOLS (not connected servers) so a zero-tool/prompt-only server doesn't make the per-turn hook fire forever. - Tests: rewrote the thread-safety test to actually exercise the write path (alternating tool sets), added the NousResearch#5544-gate regression, the memory/context preservation regression, and a "callable next turn via valid_tool_names" contract; removed a dead monkeypatch line.
…ity holes) Second review pass (Codex + Hermes subagent). Codex reproduced a real race with a two-thread harness; both converged on the remaining issues. - Generation-aware publish (fixes a lost-update race): two refresh callers (the late-refresh daemon and the between-turns prologue around turn 1) could each compute a snapshot outside the lock; a SLOWER caller holding an OLDER registry generation could acquire the publish lock after a newer caller and clobber it, deleting just-landed tools. refresh_agent_mcp_tools now captures registry._generation before computing and refuses to publish a stale set; agent._tool_snapshot_generation tracks the published generation. - Context-engine routing names (_context_engine_tool_names) are now staged on a local and published atomically with the snapshot, and only claimed when this rebuild actually appended the schema — matching agent_init's dedup so a registry/plugin tool of the same name keeps its own dispatch. (Previously mutated live, before the publish lock, and on no-change refreshes.) - CLI /reload-mcp: self.enabled_toolsets is resolved once at startup, so a server newly ENABLED in config mid-session wasn't picked up (TUI already re-resolved). Merge now-connected MCP server names into the override (unless the user pinned all/*), mirroring startup, and keep self.enabled_toolsets in sync. Closes the CLI/TUI parity hole. - ACP (acp_adapter/server.py) routed through the shared helper — it was a 5th sibling rebuild that re-injected memory tools but NOT context-engine tools and bypassed the atomic/name-diff path (inert today, fragile). - mcp_startup._resolve_discovery_timeout pulls its default from DEFAULT_CONFIG (single source of truth) instead of a stale hardcoded 5.0 literal. - Tests: stale-generation-no-clobber, _skip_mcp_refresh honored, timeout fallback uses DEFAULT_CONFIG.
…tract note Third review pass (Hermes subagent) declared convergence: no BLOCKING, the round-2 generation-aware publish / context-engine staging / CLI reload / ACP routing all verified correct by hand and by test. - agent_init: capture _tool_snapshot_generation immediately before the tool snapshot (was ~425 lines earlier); removes a harmless skew window so the recorded generation always matches the snapshot it describes. - gateway/run.py _execute_mcp_reload: keep preserving each cached agent's build-time enabled_toolsets EXACTLY (do NOT merge newly-connected servers like CLI/TUI do) and document WHY — gateway sessions can be deliberately locked down, and test_reload_mcp_preserves_per_agent_toolset_overrides asserts this. A reviewer suggested "parity" here; it would have violated that contract.
CI caught 3 ACP test failures (tests/acp/test_server.py, tests/acp/test_mcp_e2e.py). Root cause: routing ACP's tool-surface rebuild through the shared refresh_agent_mcp_tools helper (added in the round-2 pass) broke a deliberate, pre-existing ACP contract: - the ACP tests assert `agent.tools is <get_tool_definitions return>` (object identity) and an exact get_tool_definitions(enabled_toolsets=[...], disabled_toolsets=..., quiet_mode=True) call signature; the shared helper list()-copies and re-derives differently, breaking identity; and - the tests use a MagicMock agent whose _tool_snapshot_generation is a mock, so the new `int < published_gen` generation guard raised TypeError and the whole ACP refresh silently failed. ACP already preserves memory-provider tools (its own inject call) and excludes context_engine, so there was no bug to fix there — only over-reach. Reverted ACP to its original rebuild. (Same lesson as the gateway path: leave call sites that carry their own tested contract alone; a reviewer's "inert today, fragile" note meant leave-it, not change-it.) Also hardened the generation guard defensively: tolerate a non-int _tool_snapshot_generation (mock / partially-built agent) instead of throwing TypeError and silently failing the refresh.
Matches the env= callsite convention at the other sanitized subprocess spawns (cua_backend dict(os.environ), gateway os.environ.copy()). Functionally equivalent — _sanitize_subprocess_env never mutates its input — but avoids handing the live mapping to the helper. Follow-up to salvaged PR NousResearch#49207.
The cron-script subprocess is now sanitized alongside shell/MCP/ code-exec children; §2.3 listed only the original three. Makes the _run_job_script docstring's §2.3 citation fully accurate. Follow-up to salvaged PR NousResearch#49207.
…sResearch#49212) * fix(discord): hydrate channel context when replying to a message Replying to a message in a free-response (non-mention, threads-off) channel previously received only the 500-char "[Replying to: ...]" snippet — the history-backfill gate fired only for mention-gated channels and threads, so a reply got no surrounding channel context. Replies now route through the same _fetch_channel_context hydration that threads use. When the user replied to a specific (often older) message, a reply-anchored window is scanned ending at that message so the agent sees the exchange around what was pointed at, even when the target sits before the self-message partition. The two windows are merged chronologically and de-duplicated by message id. Also hardens the recent-window scan to skip non-conversational status bumps before the self-message partition check, and makes author-name resolution defensive against partial/deleted authors. * fix(discord): duck-type reply-target resolution instead of isinstance(discord.Message) The e2e suite stubs the discord module, so discord.Message is a MagicMock and isinstance(_resolved, discord.Message) raises 'isinstance() arg 2 must be a type'. Any object with an int .id works as a scan anchor, so resolve the reply target by duck-typing on .id and fall back to a _Snowflake from the reference message_id.
…ipt-env-sanitize fix(cron): sanitize env for job script subprocesses
…eepalive MCP Streamable HTTP servers that garbage-collect idle sessions on a short TTL (e.g. Unreal Engine's editor MCP, ~15s) were unusable: the keepalive was hardcoded at 180s, so the session was always dead by the time it ran, and every idle tool call then landed on an expired session and paid the full reconnect path (observed hangs of 113-143s until interrupt, bounded only by the 300s tool_timeout). Two coordinated, backward-compatible changes: - Add per-server `keepalive_interval` (config.yaml, not an env var per the contribution rubric). Default 180s — byte-identical to the old hardcoded value when unset — floored at 5s. Servers with short session TTLs set it below their TTL so the session stays warm. - Switch the keepalive probe from `list_tools()` to `ping` (the MCP base protocol liveness primitive). On large servers `list_tools` pulled ~1 MB every cycle (830 tools = 1,068,041 bytes); `ping` is ~55 bytes and works uniformly across tool/prompt/resource servers. Tool-list changes still arrive out-of-band via notifications/tools/list_changed -> _refresh_tools. `ping` is an OPTIONAL utility, so to guarantee zero regression for a tool-capable server that doesn't implement it: the first -32601 latches `_ping_unsupported` and the probe falls back to the pre-ping `list_tools` path for that connection (no reconnect loop). The latch resets on each fresh connection (_discover_tools, all transport paths) so a server that gains ping support after a reconnect is re-probed with the cheap path. Non-(-32601) ping errors propagate as genuine liveness failures. Verified end-to-end against a live Unreal MCP server (idle 22s past the ~15s TTL -> post-idle tool call returns in 0.31s, no teardown) and with a simulated ping-less tool server driving the real keepalive loop (ping once, list_tools thereafter, no reconnect). 25/25 unit tests pass. Note: a separate upstream defect (modelcontextprotocol/python-sdk#2604) still tears down the whole session when one tool-call POST returns 4xx; that is not addressed here.
Resolves conflicts in gateway/platforms/slack.py, pyproject.toml, uv.lock. Preserves 5 fork commits implementing Slack HTTP webhook mode for Aileron slackrouter. Upstream notable changes folded in: - PR NousResearch#43444 Slack approval UX block-size overflow + typed-prefix - PR NousResearch#15421 channel scope for top-level messages when reply_in_thread=false - PR NousResearch#15464 thread_ts invariant alignment - PR NousResearch#25038 lazy_deps.ensure() for slack adapter - Multiple slack/feishu/matrix lazy-install rebind fixes - Socket Mode resilience (watchdog, _start/_stop_socket_mode_handler) Upstream commit: 2bd1977 (release v0.17.0 2026.6.19)
🚨 CRITICAL Supply Chain Risk DetectedThis 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 modifiedThese files can execute code during package installation or interpreter startup. Files: 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. |
|
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-attribute |
902 |
unresolved-import |
540 |
invalid-argument-type |
374 |
invalid-assignment |
157 |
unsupported-operator |
95 |
not-subscriptable |
66 |
invalid-method-override |
29 |
invalid-parameter-default |
18 |
unused-type-ignore-comment |
13 |
no-matching-overload |
13 |
invalid-return-type |
11 |
unresolved-reference |
7 |
unresolved-global |
6 |
call-non-callable |
6 |
invalid-type-form |
4 |
| +4 more rules |
First entries
optional-skills/research/darwinian-evolver/scripts/parrot_openrouter.py:25: [unresolved-import] unresolved-import: Cannot resolve imported module `darwinian_evolver.cli_common`
tests/cron/test_blueprint_catalog.py:145: [invalid-argument-type] invalid-argument-type: Argument to function `blueprint_deeplink` is incorrect: Expected `AutomationBlueprint`, found `AutomationBlueprint | None`
tests/gateway/test_cron_fire_webhook.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_write_approval.py:204: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["Approved 2"]` and `str | None`
acp_adapter/events.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `acp.schema`
tests/agent/test_anthropic_kwargs_sanitize.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
agent/chat_completion_helpers.py:1993: [unresolved-attribute] unresolved-attribute: Attribute `append` is not defined on `None` in union `None | list[Unknown]`
tests/tools/test_x_search_tool.py:414: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
hermes_cli/cli_agent_setup_mixin.py:378: [unresolved-attribute] unresolved-attribute: Object of type `Self@_init_agent` has no attribute `_on_thinking`
tests/gateway/test_run_tool_media_re.py:20: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/platforms/discord/adapter.py:94: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `<module 'discord'>`
tests/agent/test_save_url_image.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tui_gateway/server.py:3728: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
gateway/slash_commands.py:1308: [invalid-argument-type] invalid-argument-type: Argument to function `switch_model` is incorrect: Expected `dict[Unknown, Unknown]`, found `None | Unknown`
gateway/platforms/whatsapp_cloud.py:1352: [unresolved-attribute] unresolved-attribute: Attribute `Request` is not defined on `None` in union `Unknown | None`
tests/gateway/test_telegram_channel_posts.py:52: [unresolved-attribute] unresolved-attribute: Unresolved attribute `constants` on type `ModuleType`
gateway/kanban_watchers.py:1110: [unresolved-attribute] unresolved-attribute: Object of type `~None` has no attribute `promoted`
tests/tools/test_patch_failure_tracking.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_browser_hardening.py:125: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `bound method str.__getitem__(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str` cannot be called with key of type `Literal["inactivity_timeout"]` on object of type `str`
tests/gateway/test_agent_cache.py:1312: [unresolved-attribute] unresolved-attribute: Object of type `AIAgent` has no attribute `session_id`
cli.py:14752: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `stream_delta_callback` on type `Any | None`
hermes_cli/win_pty_bridge.py:106: [unresolved-attribute] unresolved-attribute: Attribute `pid` is not defined on `None` in union `Unknown | None`
tests/hermes_cli/test_plugin_auxiliary_tasks.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/inventory.py:178: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def is_aggregator(provider: str) -> bool`
tests/cron/test_blueprint_catalog.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
... and 2222 more
✅ Fixed issues (651):
| Rule | Count |
|---|---|
unresolved-import |
236 |
invalid-argument-type |
170 |
unresolved-attribute |
107 |
invalid-assignment |
61 |
unsupported-operator |
28 |
invalid-method-override |
12 |
unresolved-reference |
9 |
not-subscriptable |
7 |
invalid-return-type |
7 |
invalid-parameter-default |
6 |
call-non-callable |
5 |
not-iterable |
1 |
no-matching-overload |
1 |
call-top-callable |
1 |
First entries
tools/rl_training_tool.py:401: [unresolved-attribute] unresolved-attribute: Unresolved attribute `env_log_file` on type `RunState`
tests/agent/test_image_routing.py:9: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
gateway/run.py:11382: [invalid-argument-type] invalid-argument-type: Argument to bound method `SessionDB.list_sessions_rich` is incorrect: Expected `str`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown]`
tools/file_operations.py:1705: [unresolved-attribute] unresolved-attribute: Object of type `~AlwaysFalsy` has no attribute `strip`
environments/hermes_swe_env/hermes_swe_env.py:47: [unresolved-import] unresolved-import: Cannot resolve imported module `atroposlib.type_definitions`
cli.py:8625: [invalid-argument-type] invalid-argument-type: Argument to bound method `AIAgent._compress_context` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | None`
skills/creative/comfyui/tests/test_extract_schema.py:5: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_clean_shutdown_marker.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/test_ctx_halving_fix.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
hermes_cli/dump.py:211: [invalid-assignment] invalid-assignment: Object of type `Literal["(unknown)"]` is not assignable to `Literal["0.13.0"]`
gateway/platforms/discord.py:4265: [unresolved-attribute] unresolved-attribute: Attribute `DMChannel` is not defined on `None` in union `Unknown | None`
environments/tool_call_parsers/hermes_parser.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `openai.types.chat.chat_completion_message_tool_call`
cli.py:6358: [invalid-argument-type] invalid-argument-type: Argument to function `_panel_box_width` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy)`
gateway/restart.py:10: [invalid-argument-type] invalid-argument-type: Argument to constructor `float.__new__` is incorrect: Expected `str | Buffer | SupportsFloat | SupportsIndex`, found `Unknown | int | str | ... omitted 12 union elements`
tests/hermes_cli/test_reasoning_effort_menu.py:29: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
tests/run_agent/test_fallback_model.py:11: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_steer.py:193: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_tool_worker_threads` of type `set[int]`
skills/red-teaming/godmode/scripts/parseltongue.py:475: [invalid-argument-type] invalid-argument-type: Argument to function `escape` is incorrect: Argument type `Sized` does not satisfy constraints (`str`, `bytes`) of type variable `AnyStr`
tests/gateway/test_telegram_topic_mode.py:1046: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
tests/hermes_cli/test_aux_config.py:46: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `Overload[(i: SupportsIndex, /) -> Unknown, (s: slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> list[Unknown]]` cannot be called with key of type `Literal["session_search"]` on object of type `list[Unknown]`
tools/rl_training_tool.py:231: [unresolved-attribute] unresolved-attribute: Attribute `loader` is not defined on `None` in union `ModuleSpec | None`
tests/hermes_cli/test_mcp_tools_config.py:92: [invalid-argument-type] invalid-argument-type: Method `__getitem__` of type `bound method str.__getitem__(key: SupportsIndex | slice[SupportsIndex | None, SupportsIndex | None, SupportsIndex | None], /) -> str` cannot be called with key of type `Literal["exclude"]` on object of type `str`
tests/integration/test_web_tools.py:431: [invalid-argument-type] invalid-argument-type: Argument to function `web_crawl_tool` is incorrect: Expected `str`, found `None | Unknown`
cli.py:6381: [invalid-argument-type] invalid-argument-type: Argument to function `_append_panel_line` is incorrect: Expected `str`, found `(str & ~AlwaysFalsy) | (list[tuple[str, str, str]] & ~AlwaysFalsy) | (int & ~AlwaysFalsy) | (Queue[Unknown] & ~AlwaysFalsy)`
tests/acp/test_permissions.py:7: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
... and 626 more
Unchanged: 3452 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
Summary
Merge upstream NousResearch/hermes-agent v2026.6.19 ("Reach Release", Hermes v0.17.0) into our fork's
feature/slack-http-modebranch, preserving our 5 fork-specific commits that ship Slack HTTP mode.This brings
mainup to ~1475 upstream commits / ~800 PRs / 1693 files / ~235k LOC delta since the previous fork base.Conflict surface
Narrow — only 3 files needed manual resolution:
gateway/platforms/slack.py— ~15 overlapping upstream changes (approval UX block-size overflow,thread_tsinvariant fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421) NousResearch/hermes-agent#15464, channel scope Slack: top-level messages create isolated sessions; sessions.json not persisting NousResearch/hermes-agent#15421,lazy_deps.ensure, plugin action handler API, video attachments, reaction lifecycle ack, etc.) all port-forwarded; our fork commits' semantic intent preserved.pyproject.toml— upstream now pinsaiohttp==3.13.4in[slack]extra; supersedes ouraiohttp==3.13.3fork commit (8a5d33718), which can be dropped on next rebase.uv.lock— regenerated.Fork commits preserved
All 5
SlackAdapterfork methods retained and verified post-merge:_verify_webhook_signature(HMAC-SHA256 webhook auth) — note: param name issig_header, notheader_sigas my original brief said. Actual upstream-compatible bug fix preserved._handle_slack_event(JSON event router)_handle_slack_interaction(form-encoded interactive payload router)_start_http_listener(aiohttp listener bound to0.0.0.0:8644)_stop_http_listener(graceful shutdown)Verification
pytest tests/test_slack.py tests/test_slack_approval_buttons.py tests/test_slack_http_interactions.py→ 222 + 7 passi-03f217e4000f1bfd8) and Saavik (i-05fce4c4294b537d1) viauv tool install --reinstall:SlackAdaptermethods present8644binds cleanlyurl_verificationPOST →HTTP 200 {"challenge": "..."}HTTP 401 "invalid signature"— proves both content-type routes are mounted under the same auth check.Known gotchas (post-merge, non-blocking)
WARNING gateway.run: Stale systemd unit detected(TimeoutStopSec=90s vs drain_timeout=180s) on every restart now. Fix:hermes gateway service install --replace.tools.browser_dialog_tool: No module named 'websockets'appears post-upgrade — v0.17 addedwebsockets==15.0.1lazy dep; should self-heal on next restart cycle.cron.scheduler ... thread_id ... lostwarnings likely fixed by upstream PR fix(slack): scope top-level channel messages by channel-only when reply_in_thread=false (#15421) NousResearch/hermes-agent#15464.Next steps after merge
v0.17.0-link.1onmain(deferred until PR merges, per Leo).ami-hermes-v0.17.0-link.1tag onaileronrepo to trigger packer bake → SSM/hermes/ami/latest.