Skip to content

Sync upstream: 518 commits from NousResearch/hermes-agent (2026-06-04)#69

Merged
S2P2 merged 519 commits into
mainfrom
sync/upstream-2026-06-04
Jun 5, 2026
Merged

Sync upstream: 518 commits from NousResearch/hermes-agent (2026-06-04)#69
S2P2 merged 519 commits into
mainfrom
sync/upstream-2026-06-04

Conversation

@S2P2

@S2P2 S2P2 commented Jun 4, 2026

Copy link
Copy Markdown
Owner

What

Sync main with upstream NousResearch/hermes-agent/main (518 commits behind as of 2026-06-04).

Merge details

  • No merge conflicts — clean auto-merge
  • 1217 files changed, +166,518 / -39,999 lines

Logic conflict verification

Ran full import-chain checks against all Eko plugin modules after merge:

Check Result
All Eko adapter imports resolve
All Eko submodules load (config, client, inbound, outbound, management, tools, adapter)
Platform("eko") resolves via dynamic _missing_()
_gateway_runner_ref importable
eko toolset present in TOOLSETS with all 3 tools
send_message_tool.py retains _send_eko_media, Eko target parsing, Eko error strings
New render_message_event / format_tool_event base methods are concrete (not abstract)
New streaming modules (stream_events.py, stream_dispatch.py) importable

Notable upstream changes

  • Streaming infrastructure: new gateway/stream_events.py + gateway/stream_dispatch.py with typed event vocabulary; BasePlatformAdapter gains render_message_event() and format_tool_event() with backward-compatible defaults
  • Media delivery hardening: masked MEDIA: tags in code blocks/JSON strings, cache_media_bytes() unified helper, response-delivery recovery for empty-after-extract edge cases
  • Gateway resilience: fd-leak fix for failed reconnects (_dispose_unused_adapter), zombie agent slot cleanup, systemd restart shortcut
  • Desktop app: new apps/desktop/ (Electron + Tauri bootstrap installer)
  • Plugin system: new hooks api_request_error, subagent_start; has_hook() utility; observer telemetry schema versioning
  • Config: transport default changed from "edit" to "auto", prefill_messages_file moved to top-level
  • Platform fixes: WhatsApp dm_policy: pairing gate, Feishu meeting invite parser, Telegram MarkdownV2 streaming drafts

Known minor issue (not blocking)

OutThisLife and others added 30 commits June 2, 2026 22:03
Two TUI polish fixes.

(1) Right-click copy now clears the highlight.
The right-click handler copied an active selection via onCopySelectionNoClear
(the copy-on-select variant that keeps the highlight during a drag) and never
cleared it, so after right-click-to-copy the selection stayed lit with no
confirmation and a follow-up right-click re-copied the stale range instead of
pasting. A successful right-click copy now clears the selection and notifies;
if the copy fails (no clipboard path) the highlight survives and we fall back
to the right-click paste handler, exactly as before.

(2) Group transcript blocks so boundaries read clearly.
Model replies, reasoning/tool trails, and system/error notes rendered with no
vertical separation, so distinct block types butted together and were hard to
scan. Group adjacent blocks by kind: one blank line opens only where the visual
group changes (model prose <-> reasoning/tool trails <-> notes), while a run of
same-kind blocks renders flush. The rule lives in domain/blockLayout.ts
(messageGroup + hasLeadGap) and is applied intrinsically in MessageLine via a
`prev` prop, which fixes the things ad-hoc per-block margins kept breaking:

  - Streaming stability: the gap is derived from the stable predecessor, never
    the live block's own changing text, so the actively-streaming reply computes
    the same gap while it streams as the settled segment does once it flushes.
    No reflow/jump.
  - Transparent empty trails: a trail hidden by /details, or one carrying only a
    token tally (the finalDetails segment message.complete appends), renders
    nothing and is transparent to grouping (prevRenderedMsg skips it), so there
    are no floating gaps, no doubled gap after a prompt, and no padded space
    above the final reply. In the default/collapsed modes content-bearing trails
    always render, so the grouping is a no-op there.

The virtual-height estimator counts the group-boundary line so scroll math
stays accurate before Yoga remeasures.

ui-tui/src/domain/blockLayout.ts (new), components/messageLine.tsx,
components/streamingAssistant.tsx, components/appLayout.tsx,
lib/virtualHeights.ts, app/useMainApp.ts.

Tests: blockLayout.test.ts (grouping + hidden/empty-trail visibility),
virtualHeights leadGap, app-mouse.test.ts copy behavior. Full ui-tui suite
green apart from 3 pre-existing local/env failures (cursorDrift, ink-resize,
virtualHeights user-prompt-width) unchanged from main.
…lick-and-boundaries

fix(tui): clear selection on right-click copy + clearer block boundaries
…horing

The thread renders virtualized turns in natural document flow with padding
spacers, and @tanstack/react-virtual already adjusts scrollTop itself when an
off-screen turn is measured and its real height differs from the 220px
estimate. With the browser default `overflow-anchor: auto`, native scroll
anchoring corrects that SAME size delta too, so the two double-correct and the
view lurches — most visibly with Windows mouse wheels, whose coarse notches
mount/measure several under-estimated turns per tick (Mac trackpads scroll
~1-3px/frame, keeping it sub-perceptual).

Set `overflow-anchor: none` on the thread viewport so only the virtualizer
compensates. Also adds `diag-scroll-reset.mjs`, a CDP wheel-up repro that A/B
tests the anchor behavior at runtime to confirm the fix.
…roll-anchor

fix(desktop): stop chat scroll jumping by disabling native scroll anchoring
Long user prompts stick to the top of the thread while the response streams
beneath them, so a multi-line prompt could eat most of the viewport. Clamp the
read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp
lifts on hover or keyboard focus, and clicking the bubble still opens the edit
composer (which shows the full text). Short messages are untouched — no clamp,
no fade.

Overflow is measured on an unclamped inner wrapper so the ResizeObserver only
fires on real content/width changes, not every frame while the outer
max-height animates open; the measured height feeds --human-msg-full so
expand/collapse animate to the true height instead of overshooting the cap.
…icket auth (NousResearch#37870)

Generalises NousResearch#37747. The WS Origin guard (_ws_host_origin_is_allowed) only
trusted the packaged Electron app's non-web origin (file:// / null / app://)
when the bind was NOT OAuth-gated. The packaged Hermes Desktop renderer loads
over file://, so when it drives a remote OAuth-gated gateway its /api/ws
upgrade was rejected with HTTP 403 even though _ws_auth_ok had already
validated the single-use ?ticket= one line earlier.

This guard runs only AFTER _ws_auth_ok has accepted the WS credential, which
is the real auth boundary in every mode:
  * loopback bind          -> legacy dashboard session token
  * non-loopback --insecure -> legacy session token (Tailscale / LAN, NousResearch#37747)
  * OAuth-gated public bind -> single-use, 30s-TTL, identity-bound ?ticket=
A non-web origin can only come from a native client; a DNS-rebinding attack
always arrives from an http(s) origin and is still match-checked against the
bound host. So once the upstream credential check has passed, the Origin guard
adds nothing for a non-web origin. Collapsed the loopback/non-gated special
cases to 'return True' for non-web origins.

http(s) origins keep the strict same-host check, so browser DNS-rebinding
defence is unchanged.

Tests: gated file:///null/app:// now asserted ALLOWED; cross-site http(s)
still rejected on gated and loopback binds; NousResearch#37747's loopback and
non-loopback-insecure cases retained. 37/37 test_dashboard_auth_ws_auth +
test_web_server_host_header pass.
Pin user bubbles 0.75rem below the scroll top via a single token instead of
flush top-0, so the sticky header doesn't sit hard against the thread edge.
…icky-msg-clamp

feat(desktop): clamp sticky human messages to ~2 lines until hover/focus
Replace Electron's built-in zoomIn/zoomOut/resetZoom menu roles with
custom implementations that use a 0.1 zoom-level step instead of
Chromium's default 0.2. This makes Ctrl/Cmd + +/-0 zoom feel more
granular and less jumpy.

Also adds installZoomShortcuts() which intercepts the keyboard shortcuts
via before-input-event. This is necessary on Linux/Windows where the
application menu is set to null, so Chromium's default handler would
otherwise apply the full 0.2 step.
…ATE on first boot (NousResearch#37896)

On a fresh volume there is no gateway_state.json, so the boot reconciler
(cont-init.d/02-reconcile-profiles) registers the gateway-default s6 slot
but leaves it down — it only auto-starts when the last recorded state was
"running". A freshly-provisioned container therefore comes up with the
gateway down until something starts it (e.g. the dashboard's start button).

Add a generic, first-boot-only env-seed in stage2-hook.sh (which runs
before 02-reconcile-profiles): when HERMES_GATEWAY_BOOTSTRAP_STATE=running
and no gateway_state.json exists yet, seed {"gateway_state":"running"} so
the reconciler brings the supervised slot up on the very first boot.

This mirrors the existing HERMES_AUTH_JSON_BOOTSTRAP pattern: it seeds the
same state file the reconciler already consults, guarded by [ ! -f ] so
persisted runtime state always wins on later boots (a deliberately-stopped
gateway stays stopped across restarts). Only the literal "running" is
honoured (the sole value in the reconciler's _AUTOSTART_STATES).

Generic container contract — no host-specific code. Useful to any
orchestrator that provisions a blank volume and wants the gateway up from
first boot (the supervised gateway/dashboard already work on such hosts;
only the first-boot autostart was missing because the CLI lifecycle
commands can't drive the s6 layer when container self-detection misses).

Adds a shell-level contract test and documents the env var.
Creating several sessions in a row (Ctrl-N, type, send, repeat) and
waiting for one to finish made the other still-running chats disappear
from the sidebar.

Root cause: a new session's first user message isn't flushed to the
SessionDB until its turn is persisted, so the row's message_count stays
0 mid-response. `refreshSessions()` lists with min_messages=1 and then
hard-replaces $sessions. Because every message.complete triggers a
refresh, the moment one session finished, the others (still at
message_count 0) were filtered out of the server page and dropped from
the list.

Fix: merge instead of replace. `mergeWorkingSessions()` preserves any
session that is still in $workingSessionIds but absent from the server
page, so concurrent new chats stay visible until their own turn persists.
Optimistic deletes/archives already remove the row from the previous
list, so a removed session can't be resurrected by the merge.
The send path created the optimistic sidebar row with a null preview, so
a new chat read "Untitled session" until its turn persisted and auto-title
ran. With concurrent new chats now preserved across refreshes, several
"Untitled session" rows could show at once.

Seed the optimistic preview with the user's first message (the branch path
already does this) so each in-flight row is labeled immediately. The
server's own preview/title supersedes it once the turn persists.
…ncurrent-session-loss

fix(desktop): keep in-flight new chats from vanishing on refresh
…NousResearch#37923)

The embedded dashboard Chat tab dies on hosted images with a 502 /
"[session ended]": the PTY child's `hermes --tui` spawn runs a runtime
`npm install` that fails.

Root cause: the root package-lock.json describes the WHOLE npm monorepo
workspace set (root + web + ui-tui + apps/*), but the image only installs
root/web/ui-tui — apps/* (the desktop app) is never `npm install`ed here, and
its deps hoist into the shared root node_modules. So the actualized
node_modules permanently disagrees with the canonical lock,
`_tui_need_npm_install()` returns True on every launch, and the runtime
`npm install` it triggers (a) can never converge against the partial monorepo
and (b) races itself across concurrent /api/pty connections -> ENOTEMPTY ->
the launcher `sys.exit(1)`s, the slow install blows past Fly's WS-upgrade
window -> 502 -> the browser shows "[session ended]".

Fix: set `ENV HERMES_TUI_DIR=/opt/hermes/ui-tui` so `_make_tui_argv` takes the
prebuilt-bundle fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`)
and never reaches the install check — exactly the nix/packaged-release path
the launcher was designed for. The bundle is already built at Layer 8
(`ui-tui && npm run build`); this just tells the launcher to use it.

Verified on a freshly-built image: HERMES_TUI_DIR is set, the prebuilt
dist/entry.js is present, `_make_tui_argv` resolves to the prebuilt node
invocation (no npm), and `docker run ... --tui` no longer prints
"npm install failed". New regression guard: tests/docker/test_tui_prebuilt_bundle.py.

A separate launcher hardening (make _tui_need_npm_install tolerant of
partial-monorepo installs) is tracked independently; this Docker-side fix
resolves the hosted-chat symptom on its own.

Area: docker (Dockerfile + tests/docker).
…cker

Users on remote/forwarded displays (SSH X11 forwarding, VNC, RDP, WSLg)
reported the window flickering during scroll/streaming; nobody on native
Windows/macOS ever saw it.

Root cause: the app shipped with Chromium's default GPU hardware
acceleration and no remote-display handling. Over a remote connection the
GPU compositor can't present accelerated layers cleanly across the wire,
so the surface flashes on repaint. Local sessions composite on the GPU
and never hit it.

Detect a remote display before app `ready` (detectRemoteDisplay in
bootstrap-platform.cjs) and fall back to software rendering via
app.disableHardwareAcceleration() + --disable-gpu-compositing. Software
compositing is rock-steady over the wire and the CPU cost is negligible
next to the connection's latency. HERMES_DESKTOP_DISABLE_GPU overrides
detection both ways for VNC/screen-sharing setups we can't sniff or
remote hosts that do have working acceleration.
WSLg renders Linux GUIs locally through a vGPU surface rather than
shipping frames over the wire, so it doesn't show the remote-compositor
flicker — confirmed by a WSL user seeing zero flickering. Drop the WSL
branch from detectRemoteDisplay so WSLg keeps hardware acceleration;
detection now covers only genuinely-remote displays (SSH X11 forwarding,
VNC, RDP). The HERMES_DESKTOP_DISABLE_GPU override still works for anyone
who does hit it.
…mote-flicker

fix(desktop): disable GPU acceleration on remote displays to stop flicker
The desktop composer's `onKeyUp` handler unconditionally re-ran
`refreshTrigger` on every keyup, including the Arrow/Enter/Tab/Escape keys
the open-trigger `onKeyDown` branch had already fully handled. Because
`refreshTrigger` re-detects the trigger and resets the active index to 0,
this produced two bugs in the `/` (and `@`) completion popover:

- ArrowDown/ArrowUp moved the highlight on keydown, then keyup snapped it
  straight back to the top — so the user could never cycle past the first
  couple of items.
- Escape closed the menu on keydown, then keyup re-detected the still-present
  `/` and immediately reopened it — so Esc appeared to do nothing.

Fix: skip the keyup-driven refresh for the navigation/control keys while a
trigger menu is open (they never edit text, so refreshing is pointless), and
only reset the highlight in `refreshTrigger` when the detected trigger query
actually changed. Applied to both the main composer (chat/composer/index.tsx)
and the message-edit composer (assistant-ui/thread.tsx), which shared the
same bug. New `shouldSkipTriggerRefreshOnKeyUp` helper is unit-tested.
…slash-menu-keyup-nav

fix(desktop): keep slash/@ completion menu navigable and Esc-dismissable
When a follow-up message is queued during a busy turn, the composer
clears and the primary button switches back to the Stop affordance. But
clicking Stop ran interruptAndSendNextQueued(), which cancelled the turn
and *immediately* re-sent the head of the queue. The auto-drain effect
(busy true to false) compounded this: any explicit cancel flipped busy
false and re-fired the queue. The net effect was that Stop appeared to
never interrupt -- the agent kept running on the queued prompt.

Fix:
- Stop button (busy + empty composer) now always performs a pure
  interrupt via onCancel(); it no longer hijacks the queue.
- An explicit interrupt latches userInterruptedRef so the busy to false
  auto-drain skips exactly one drain. Queued turns are preserved and the
  user resumes them deliberately (Cmd/Ctrl+K, Enter, or the per-row
  send-now arrow), matching the documented Esc=cancel / Cmd+K=send-next
  affordances.
- Extracted the settle decision into shouldAutoDrainOnSettle() with unit
  tests covering natural completion vs. explicit interrupt.
…stop-button-interrupt

fix(desktop): make Stop button actually interrupt when a turn is queued
…ve transcript

A still-busy background session (one the user toggled away from) keeps
emitting updateSessionState() heartbeats — stream deltas, and especially
the 'session busy' prompt-rejection errors from auto-drained queued turns.
Each call invoked syncSessionStateToView() unconditionally, staging that
session's messages into the shared $messages view.

flushPendingViewState() guarded against the wrong session reaching the
view, but only one requestAnimationFrame is scheduled per frame and
pendingViewStateRef holds just the latest writer. So within a single
frame a background write could overwrite an already-pending foreground
write, and the stale background transcript (e.g. the red 'session busy'
rows) would render on top of whatever session the user switched to —
appearing to 'bleed' into every session.

Guard at the staging site: a session may only stage into the view when
it is the currently-active session. Background sessions still update
their own cache entry; they just never touch $messages. Pure render
fix, no behavior change to queuing, interrupt, or drain.
…ss-internal credential

The embedded-TUI PTY child attaches to two server-internal WebSockets:
/api/ws (its primary JSON-RPC gateway backend) and /api/pub (the event
sidecar). Both URLs are built server-side in web_server.py and handed to
the child via its environment.

In OAuth-gated mode (auth_required=true, every hosted Fly agent), _ws_auth_ok
unconditionally rejects the legacy ?token=<_SESSION_TOKEN> path — a leaked
session token must not grant WS access once the gate is engaged. But
_build_gateway_ws_url() still only emitted ?token=, with no gated-mode
branch (its sibling _build_sidecar_url had been given a ticket branch; the
gateway-url builder was missed). So the TUI child's /api/ws upgrade was
rejected 4401 -> 'gateway websocket connection failed' -> 'gateway startup
timeout', leaving the embedded chat unusable on every gated deployment.

A single-use 30s browser ticket is the wrong shape for this link: the child
reads its attach URL once at startup and reuses it on every reconnect, and
on a slow cold boot it may not dial within the TTL. (_build_sidecar_url's
own docstring already flagged this fragility.)

Fix: add a process-lifetime, multi-use internal credential to
dashboard_auth.ws_tickets (internal_ws_credential / consume_internal_credential),
minted once per process and NEVER injected into the SPA — it only leaves the
process via a spawned child's env, so browser-side XSS can't read it, and a
leak grants no more than a ticket already does. _ws_auth_ok accepts it via
?internal= in gated mode only. Both _build_gateway_ws_url and
_build_sidecar_url now use it, so the child can reconnect both sockets.

Loopback / --insecure behavior is unchanged (still ?token=).

Needs review: touches _ws_auth_ok + dashboard_auth (core auth surface).
…cstring fix

Follow-up to Ben's PR NousResearch#37892. Adds a TestInternalCredential block to
test_dashboard_auth_ws_tickets.py exercising the mint-once stability,
multi-use, unminted-rejection, empty-value, wrong-value, reset-and-remint,
and ticket-store-independence branches directly (previously only covered
indirectly via _ws_auth_ok, which left the unminted and empty-value
branches unexercised).

Also corrects the consume_internal_credential docstring: the returned
identity dict is discarded by the current _ws_auth_ok caller (which only
needs the boolean outcome), so the prior 'carry it into its session log'
wording over-promised.
The existing slash-menu fix (PR NousResearch#37937) shipped a unit test that drove the
keydown reducer directly. It did not exercise the actual DOM event path —
specifically the keyup-driven `refreshTrigger` that was the root cause — so
it would not have caught a regression in that path.

This adds a faithful @testing-library reproduction that mounts the real
`useLiveCompletionAdapter` plus the index.tsx trigger wiring and fires real
`keyDown` + `keyUp` event pairs on a contentEditable. It asserts:

- ArrowDown cycles through ALL items (0,1,2,3,4,0,1), not just the first two
- Escape closes the menu and keyup does not reopen it

Reverting the fix (always-refresh keyup + unconditional setTriggerActive(0))
makes this test fail with the highlight stuck at the top — confirming it
guards the real bug.
Follow-up to NousResearch#37937. That fix guarded the composer's keyup with
`shouldSkipTriggerRefreshOnKeyUp(key, trigger !== null)`. The `trigger !== null`
check is timing-fragile for Escape: Escape's *keydown* sets `trigger = null`
and closes the menu, but in a real browser the *keyup* fires after a re-render,
so the handler closure sees `trigger === null`, the guard returns false,
`refreshTrigger` runs, re-detects the still-present `/` in the input, and
instantly reopens the menu. (jsdom batches state synchronously so a unit test
could not observe this -- only the running app does.)

Replace the value-based guard with a `triggerKeyConsumedRef` set synchronously
in keydown whenever the open popover consumes a nav/control key
(Arrow/Enter/Tab/Escape). keyup consults and clears that ref, so it is immune
to the keydown->re-render->keyup timing. Applied to both the main composer
(chat/composer/index.tsx) and the message-edit composer
(assistant-ui/thread.tsx).

Removes the now-unused `shouldSkipTriggerRefreshOnKeyUp` helper and its unit
test. The real-DOM regression test now fires keydown+keyup pairs through the
ref-based handlers and asserts Esc closes and stays closed.

Verified by running a production renderer build (Vite v8) under Electron
against a local backend: ArrowDown/ArrowUp cycle the full list and Esc
dismisses the menu without reopening.
ethernet8023 and others added 23 commits June 4, 2026 09:51
…rch#39100)

- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding)
- flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg
- add setMutableRef helper for imperative ref writes (react-compiler clean)
- move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
hermes desktop failed on Linux with an ENOENT renaming
release/linux-unpacked/electron -> Hermes. Root cause is a corrupt
cached Electron zip (~/.cache/electron/electron-*.zip): app-builder
unpack-electron extracts a partial tree from the bad zip that is
missing the electron binary, so electron-builder dies on the final
rename. Re-running repeats the broken extraction, leaving the desktop
app permanently unlaunchable until the cache is manually purged.

- Add _electron_download_cache_dirs() + _purge_corrupt_electron_cache()
  to hermes_cli/main.py: validate every electron-*.zip via
  zipfile.testzip() and delete corrupt ones; honor electron_config_cache
  / ELECTRON_CACHE overrides with per-OS defaults.
- Wire purge + single retry into cmd_gui packaged-build failure path so
  a poisoned download self-heals (electron re-downloads clean).
- Add beforePack hook (apps/desktop/scripts/before-pack.cjs) to wipe the
  target unpacked dir before staging, making packaging idempotent across
  interrupted runs. Cross-platform, best-effort.
- Tests: corrupt-zip detector, cmd_gui purge/retry/launch path,
  no-retry-when-clean path, and node --test for the cleanup helper.
…pfile gate

The salvaged detector validated each cached electron-*.zip with
zipfile.testzip() and only purged ones it judged corrupt. But stdlib
zipfile reads from the end-of-central-directory backward, so it silently
tolerates prepended/concatenated junk — which is exactly the corruption
the bug report names ('86257938 extra bytes at beginning or within
zipfile', a partial download resumed into the same file). testzip()
returns clean on those zips, so the self-heal never fired for the
reported failure mode.

Drop the self-rolled validator: on any packaged-build failure, purge the
version's cached zips AND the half-written unpacked dir, then retry once.
@electron/get re-downloads with its own SHASUM verification — the real
source of truth, which catches prepend/concat/truncate alike. An
unrelated failure just costs one clean re-download and fails the same way.

Verified empirically: zipfile.testzip() returns None (clean) on a
prepended-junk zip; the unconditional purge removes it correctly.
…nfig apply

The apply handler sent SIGTERM then fired a 150 ms setTimeout to reload
the renderer. If the backend took longer to shut down the port was still
bound when startHermes() ran after reload, causing an "address already
in use" failure.

Capture the process reference before resetHermesConnection() nulls it,
then await the actual exit event. A 5 s SIGKILL fallback ensures the
wait never hangs if the backend ignores SIGTERM.
POST /api/profiles returns model_set: false when the model assignment
step fails (e.g. filesystem error) while the profile itself was created
successfully. handleCreate discarded the response, so the user received
a "Profile created" success toast with no indication that their chosen
model was not persisted.

Capture the response and show an error toast when a model was selected
but model_set is explicitly false, directing the user to set it from
the profile editor.
handleSaveDesc and handleAutoDescribe both set their loading flag in a
try block but always cleared it unconditionally in finally. When a user
opened profile A's description editor, clicked Save, then quickly
switched to profile B's editor and saved, profile A's resolving request
would clear descSaving/describing while profile B's request was still
in-flight, making the "Saving…" indicator disappear prematurely.

Track concurrent in-flight counts with descSavingCount and
describingCount refs (mirrors the existing activeDescRequest guard
pattern). The loading flag is cleared only when the counter reaches
zero, i.e. all overlapping requests have settled.
…se endpoints

The dashboard specify and decompose endpoints run as sync FastAPI threadpool
handlers and pinned the active board by mutating the process-global
HERMES_KANBAN_BOARD env var. Two concurrent requests for different boards
race on that shared global and cross-write — the same bug class as the CLI
path (NousResearch#38323), now using the scoped_current_board() contextvar introduced by
the CLI fix.
search_sessions_by_id previously fetched up to 10k sessions via
list_sessions_rich and filtered them in Python — O(n) per keystroke.
Push the id match into SQL instead.

- list_sessions_rich gains an optional id_query param: a case-insensitive
  LIKE pushed into the outer WHERE, matched against each surfaced row's id
  AND every id in its forward compression chain (via the existing chain
  CTE). Searching a compression root id or a tip id both resolve to the
  same projected conversation. LIKE wildcards in the needle are escaped.
- search_sessions_by_id now fetches only matching rows (limit*4) and ranks
  exact > prefix > substring in Python over that small set.
- web_server /api/sessions/search: route ID matches and content matches
  through one lineage-keyed dedup helper so an id-hit and a content-hit on
  the same conversation collapse to a single result (the contributor's
  version keyed ID hits by raw sid and content hits by root, which could
  double-list a compression tip).
- command-center haystack also matches _lineage_root_id for parity.

E2E verified against a real DB: exact match over 3000+ sessions
materializes 1 row in Python (was ~3000), 5ms; root-id resolves to tip;
LIKE-wildcard escaping holds.

Follow-up to @0xharryriddle's feat(desktop): search sessions by id.
Root-run gateways have $HOME=/root, which is on the MEDIA system-path
denylist, so the gateway silently dropped agent-generated deliverables
under /root (e.g. /root/work/proposal.docx) — the user got a 'here is
your file' reply with nothing attached.

_path_under_denied_prefix now treats the running user's own home as
deliverable: the home tree itself is no longer denied, while the
more-specific denied paths inside it (~/.ssh, ~/.aws, ~/.hermes/.env,
auth.json, config.yaml) stay blocked because they are separate denylist
entries. The exception only matches when the denied prefix IS $HOME, so
a non-root gateway still can't deliver another user's home.

Diagnosis, reproduction, and the failing-case analysis are from
@GodsBoy (NousResearch#38108 / NousResearch#38106). Implemented here as the minimal denylist
fix rather than a staging/copy subsystem.

Co-authored-by: GodsBoy <dhuysamen@gmail.com>
…ght run

A session_reset (/new, /cc) that bumps the run generation while an agent
turn is in flight left the dead agent in the _running_agents slot: the
in-flight run's own release is generation-guarded and correctly returns
False, and the outer finally's sentinel-only check also missed the
leftover real agent. The session then silently dropped every subsequent
message as 'agent busy' until a full gateway restart. (NousResearch#28686)

- _process_message_or_command outer finally now calls the unconditional,
  idempotent _release_running_agent_state(key) on all exit paths instead
  of the sentinel-vs-else branch that could strand a dead agent.
- _handle_reset_command evicts the slot right after bumping the
  generation, so the zombie is cleared at reset time regardless of how
  the in-flight run unwinds.

Co-authored-by: CryptoByz <cryptobyz.airdrop@gmail.com>
…sResearch#37721)

quick() and dry_run() previously trusted the stored category from
tracked.json without re-validating at delete time. Stale entries from
before NousResearch#34840 could carry category="cron-output" for cron control-plane
paths (e.g. cron/jobs.json), causing quick() to delete the live
scheduler registry.

Fix:
- Fix guess_category() to only classify cron/output/** as cron-output
  (was classifying ALL cron/* paths, missing the NousResearch#34840 fix).
- Re-validate cron-output entries via guess_category() at delete time
  in quick() and dry_run(); stale entries that are no longer classified
  as cron-output are skipped and removed from tracked.json.
- Add _is_protected_cron_path() as a hard defense-in-depth guard that
  blocks deletion of cron/cronjobs directories and known control-plane
  files (jobs.json, .tick.lock) regardless of stored category.
- Update test_cron_subtree_categorised to match fixed guess_category
  (only cron/output/* is cron-output, not all of cron/).

Tests: add 5 regression tests in TestStaleCronEntryMigration.
The salvaged fix held _session_resume_lock across _make_agent (MCP discovery
+ AIAgent construction, seconds), serializing it against session.close. Since
session.close runs on the main RPC dispatch thread (not a _LONG_HANDLER), a
close racing a mid-build resume would stall all fast-path RPCs (approval.respond,
session.interrupt).

Restructure to double-checked locking: build the agent outside the lock, then
re-check _find_live_session_by_key under the lock before _init_session. A losing
concurrent resume discards its just-built agent (no worker/poller wired yet) and
reuses the winner. Updated the concurrent-resume regression test to assert the
real invariant (one surviving live session + loser agent closed) rather than the
implementation detail of a single _make_agent call.
…build

hermes update can brick a Windows install. When 'hermes update --force' runs
past the concurrent-process guard, rebuild_venv runs while the venv is still in
use: shutil.rmtree(ignore_errors=True) deletes site-packages + certifi's cert
bundle but can't remove the locked python.exe, leaving a half-gutted venv that
uv venv then refuses to overwrite. Every later HTTPS call dies with
FileNotFoundError for the missing cacert and there is no recovery.

--clear alone (the c136eb4 retry path) does not fix the real lock case: when
the locked interpreter is *inside* the venv being rebuilt, neither rmtree nor
uv venv --clear can delete it. os.replace of the parent directory *is* allowed
on Windows (a running .exe is tracked by handle, not path), so we move the old
venv aside atomically to <venv>.old, rebuild with --clear in its place, and the
still-running gateway/desktop keep using the moved-aside copy until they
restart. If the venv genuinely can't be moved, we abort cleanly and leave it
fully intact; if the rebuild fails, we restore the moved-aside copy.

Folds in the call-site guards from NousResearch#38511 (@f3rs3n):
- rebuild_venv() returns False (and restores the backup) if uv exits 0 without
  producing an interpreter.
- both hermes update venv-rebuild call sites abort with RuntimeError instead of
  continuing into dependency install when rebuild_venv() returns False.

Also gitignore /venv.old/ so the update autostash (git stash --include-untracked)
doesn't sweep the moved-aside venv on every run.

Root-cause fix for NousResearch#37881. Supersedes the --clear-only retry from c136eb4.

Co-authored-by: f3rs3n <32328813+f3rs3n@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

🔎 Lint report: sync/upstream-2026-06-04 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: 9869 on HEAD, 9562 on base (🆕 +307)

🆕 New issues (189):

Rule Count
unresolved-import 59
unresolved-attribute 47
invalid-argument-type 43
invalid-assignment 19
unsupported-operator 5
not-subscriptable 5
no-matching-overload 5
call-non-callable 2
unsupported-base 1
unused-type-ignore-comment 1
deprecated 1
unresolved-reference 1
First entries
agent/conversation_compression.py:679: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
plugins/dashboard_auth/self_hosted/__init__.py:77: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/gateway/test_feishu_meeting_invite.py:103: [unresolved-attribute] unresolved-attribute: Attribute `event_id` is not defined on `None` in union `MeetingInvitedPayload | None`
tests/gateway/test_run_tool_media_re.py:20: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/agent/test_curator.py:999: [unsupported-operator] unsupported-operator: Operator `>` is not supported between objects of type `Unknown | int | str | ... omitted 4 union elements` and `Literal[0]`
tests/gateway/test_feishu_meeting_invite.py:109: [unresolved-attribute] unresolved-attribute: Attribute `union_id` is not defined on `None` in union `MeetingInviteUser | None`
tests/tools/test_stage2_hook_user_flag_guard.py:31: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/run_agent/test_run_agent.py:2858: [unresolved-attribute] unresolved-attribute: Attribute `orelse` is not defined on `stmt` in union `stmt | (Unknown & ~None)`
tests/hermes_cli/test_inventory_pricing.py:40: [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["a/paid"]` on object of type `str`
tests/hermes_cli/test_mcp_startup.py:150: [invalid-assignment] invalid-assignment: Object of type `() -> None` is not assignable to attribute `_install_tool_callbacks` of type `def _install_tool_callbacks(self) -> None`
optional-skills/creative/pixel-art/scripts/pixel_art.py:21: [unresolved-import] unresolved-import: Cannot resolve imported module `palettes`
tests/plugins/dashboard_auth/test_basic_provider.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_per_platform_streaming_defaults.py:17: [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["telegram"]` on object of type `str`
tests/gateway/test_platform_reconnect_fd_leak.py:344: [invalid-assignment] invalid-assignment: Object of type `def _boom() -> None` is not assignable to attribute `close` of type `def close(self) -> None`
tests/gateway/test_telegram_send_draft_format.py:101: [invalid-assignment] invalid-assignment: Object of type `(c) -> str` is not assignable to attribute `format_message` of type `def format_message(self, content: str) -> str`
tests/hermes_cli/test_web_server.py:1161: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `None` in union `Any | None`
tests/hermes_cli/test_kanban_decompose_db.py:228: [not-subscriptable] not-subscriptable: Cannot subscript object of type `None` with no `__getitem__` method
tests/gateway/test_per_platform_streaming_defaults.py:18: [not-subscriptable] not-subscriptable: Cannot subscript object of type `float` with no `__getitem__` method
tests/hermes_cli/test_web_server.py:490: [unresolved-attribute] unresolved-attribute: Attribute `commit` is not defined on `None` in union `Connection | None`
hermes_cli/curses_ui.py:489: [invalid-argument-type] invalid-argument-type: Argument to function `_filter_indices` is incorrect: Expected `list[str]`, found `Unknown | None`
tests/hermes_cli/test_tools_config.py:725: [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["browser_provider"]` on object of type `str`
tests/gateway/test_feishu_meeting_invite.py:130: [unresolved-attribute] unresolved-attribute: Attribute `meeting_no` is not defined on `None` in union `MeetingInviteMeeting | None`
tests/hermes_cli/test_web_server.py:488: [unresolved-attribute] unresolved-attribute: Attribute `execute` is not defined on `None` in union `Connection | None`
plugins/platforms/simplex/adapter.py:308: [invalid-argument-type] invalid-argument-type: Argument to bound method `SimplexAdapter._handle_new_chat_item` is incorrect: Expected `dict[Unknown, Unknown]`, found `Unknown | None | dict[Unknown, Unknown]`
optional-skills/creative/pixel-art/scripts/pixel_art.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `.palettes`
... and 164 more

✅ Fixed issues (36):

Rule Count
invalid-argument-type 9
unresolved-import 9
unsupported-operator 5
unresolved-attribute 4
invalid-return-type 2
unresolved-reference 2
invalid-assignment 2
no-matching-overload 1
not-subscriptable 1
invalid-parameter-default 1
First entries
tests/cron/test_cron_workdir.py:223: [invalid-argument-type] invalid-argument-type: Argument to bound method `list.append` is incorrect: Expected `tuple[str, bool]`, found `tuple[Unknown, str]`
hermes_cli/setup.py:789: [invalid-argument-type] invalid-argument-type: Argument to function `load_pool` is incorrect: Expected `str`, found `None | Unknown`
hermes_cli/tools_config.py:2972: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `dict[Unknown, Unknown]` and `str | list[dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[Unknown]] | dict[str, str | list[dict[str, str]]]] | list[dict[str, str | list[Unknown] | bool | list[str]] | dict[str, str | list[dict[str, str]]]] | ... omitted 4 union elements`
tests/agent/test_auxiliary_config_bridge.py:284: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["provider"]` and `Unknown | int | str | ... omitted 12 union elements`
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`
plugins/example-dashboard/dashboard/plugin_api.py:9: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi`
tests/hermes_cli/test_aux_config.py:41: [unsupported-operator] unsupported-operator: Operator `>` is not supported between objects of type `Unknown | int | str | ... omitted 3 union elements` and `Literal[0]`
skills/creative/pixel-art/scripts/pixel_art.py:21: [unresolved-import] unresolved-import: Cannot resolve imported module `palettes`
hermes_cli/setup.py:845: [no-matching-overload] no-matching-overload: No overload of bound method `dict.get` matches arguments
tests/gateway/test_simplex_plugin.py:312: [unresolved-import] unresolved-import: Cannot resolve imported module `websockets.client`
skills/creative/pixel-art/scripts/pixel_art_video.py:26: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
skills/creative/pixel-art/scripts/palettes.py:152: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
gateway/run.py:9974: [invalid-argument-type] invalid-argument-type: Argument to function `build_recap` is incorrect: Expected `str | None`, found `Literal["local", "telegram", "discord", "whatsapp", "slack", ... omitted 17 literals] | set[Unknown] | None`
hermes_cli/main.py:4738: [unresolved-import] unresolved-import: Cannot resolve imported module `simple_term_menu`
hermes_cli/auth.py:6131: [unresolved-import] unresolved-import: Cannot resolve imported module `simple_term_menu`
tui_gateway/server.py:2065: [invalid-argument-type] invalid-argument-type: Argument to `AIAgent.__init__` is incorrect: Expected `str`, found `(Unknown & ~AlwaysFalsy) | (str & ~AlwaysFalsy) | None`
hermes_cli/config.py:3671: [invalid-return-type] invalid-return-type: Return type does not match returned value: expected `tuple[int, int]`, found `tuple[Any, str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 29 union elements]`
hermes_cli/config.py:4317: [unresolved-attribute] unresolved-attribute: Attribute `items` is not defined on `int`, `str`, `list[Unknown]`, `float`, `None` in union `Unknown | int | str | ... omitted 12 union elements`
skills/creative/pixel-art/scripts/pixel_art.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `.palettes`
plugins/memory/honcho/__init__.py:584: [unresolved-attribute] unresolved-attribute: Attribute `get_prefetch_context` is not defined on `None` in union `None | HonchoSessionManager`
skills/creative/pixel-art/scripts/pixel_art.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `PIL`
gateway/platforms/telegram.py:460: [unresolved-reference] unresolved-reference: Name `Set` used when not defined: Did you mean `set`?
tests/agent/test_auxiliary_config_bridge.py:285: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["model"]` and `Unknown | int | str | ... omitted 12 union elements`
tui_gateway/server.py:1657: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["duration_s"]` and value of type `int | float` on object of type `dict[str, str]`
skills/productivity/linear/scripts/linear_api.py:76: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["variables"]` and value of type `dict[str, Any] & ~AlwaysFalsy` on object of type `dict[str, str]`
... and 11 more

Unchanged: 4929 pre-existing issues carried over.

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

@S2P2

S2P2 commented Jun 4, 2026

Copy link
Copy Markdown
Owner Author

Review: Sync upstream (518 commits from NousResearch/hermes-agent)

Overall Assessment: Approve with one actionable item

Thorough pre-merge verification. Clean auto-merge, no conflicts. The Eko plugin sits in plugins/platforms/eko/ which upstream doesn't touch — natural isolation. The critical Eko-compatibility surface checks out.


What looks good ✅

  1. Clean merge, no conflicts. Merge-base confirms the fork branched from a recent main. Eko plugin files are untouched by upstream.

  2. BasePlatformAdapter new methods are concrete, not abstract. render_message_event() and format_tool_event() ship with backward-compatible defaults that reproduce existing behavior 1:1. Eko inherits them for free with zero code changes.

  3. Eko toolset intact. toolsets.py contains "eko" with all 3 tools (eko_create_group, eko_create_topic, eko_query_users). Upstream didn't touch toolsets.py at all.

  4. send_message_tool.py untouched by upstream. The 21 Eko references (including _send_eko_media, Eko target parsing, error strings) survive the merge unchanged.

  5. New streaming infrastructure (stream_events.py, stream_dispatch.py) is additive. These are new modules the gateway consumes; Eko's inbound/outbound paths don't reference them and aren't affected.

  6. cache_media_bytes / CachedMedia added upstream in base.py — Eko doesn't use them (imports only cache_image_from_bytes, cache_image_from_url), so no conflict.

  7. Known issue Eko interactive_setup: get_env_var/set_env_var no longer in hermes_cli.config #68 is correctly scoped. get_env_var/set_env_var are removed from upstream's hermes_cli/config.py. The Eko adapter has except ImportError with a manual-print fallback. This only affects interactive_setup() — not runtime messaging.

  8. Dockerfile improvements are solid:

    • HERMES_TUI_DIR env var points at prebuilt bundle (fixes the 502 on hosted images)
    • /usr/bin/tini/init backward-compat shim
    • python3-venv and libolm-dev added to apt packages
    • Submodule references removed (fork has no submodules)
  9. Security-relevant changes in base.py are improvements:

    • _HERMES_ROOT added to denied-paths list (covers shared Hermes root credentials)
    • Home-directory exception for root-run gateways is well-documented and narrow

Actionable item ⚠️

Issue #68 (get_env_var/set_env_var removal) should be fixed before or shortly after merge.

While the except ImportError fallback means runtime messaging won't break, hermes eko setup (the interactive_setup wizard) will print the fallback message and exit without writing any env vars. This is the only path for new users to configure Eko credentials. The fix is straightforward — replace with direct .env file reads/writes using the patterns already in the codebase:

# Instead of:
from hermes_cli.config import get_env_var, set_env_var

# Use direct .env read/write:
from hermes_constants import get_hermes_home
# read/write ~/.hermes/.env directly

This should be tracked as a follow-up PR or patched into this branch before merge.


Minor observations (non-blocking)

  1. MEDIA_TAG_CLEANUP_RE now accepts Windows paths (X:\ / X:/) — upstream added this. No impact on Eko, good for Windows users.

  2. Docker packages: write permission added for ghcr.io build cache — this is the arm64 registry-cache migration. GITHUB_TOKEN is job-scoped and auto-expires.

  3. Nix CI simplified — removed the separate nix build step, now relies on nix flake check only. Inert for this fork.

  4. .envrc watch_file list expanded to cover the new monorepo workspace layout. Only matters if you use direnv.

  5. CONTRIBUTING.md drops --recurse-submodules — matches upstream removing submodules. Correct for this fork.

@S2P2 S2P2 merged commit 8910771 into main Jun 5, 2026
26 of 27 checks passed
@S2P2 S2P2 deleted the sync/upstream-2026-06-04 branch June 5, 2026 02:05
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.