Sync upstream: 518 commits from NousResearch/hermes-agent (2026-06-04)#69
Conversation
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.
… target platform reconnects
…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>
🔎 Lint report:
|
| 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.
Review: Sync upstream (518 commits from NousResearch/hermes-agent)Overall Assessment: Approve with one actionable itemThorough pre-merge verification. Clean auto-merge, no conflicts. The Eko plugin sits in What looks good ✅
Actionable item
|
What
Sync
mainwith upstreamNousResearch/hermes-agent/main(518 commits behind as of 2026-06-04).Merge details
Logic conflict verification
Ran full import-chain checks against all Eko plugin modules after merge:
Platform("eko")resolves via dynamic_missing_()_gateway_runner_refimportableekotoolset present inTOOLSETSwith all 3 toolssend_message_tool.pyretains_send_eko_media, Eko target parsing, Eko error stringsrender_message_event/format_tool_eventbase methods are concrete (not abstract)stream_events.py,stream_dispatch.py) importableNotable upstream changes
gateway/stream_events.py+gateway/stream_dispatch.pywith typed event vocabulary;BasePlatformAdaptergainsrender_message_event()andformat_tool_event()with backward-compatible defaultscache_media_bytes()unified helper, response-delivery recovery for empty-after-extract edge cases_dispose_unused_adapter), zombie agent slot cleanup, systemd restart shortcutapps/desktop/(Electron + Tauri bootstrap installer)api_request_error,subagent_start;has_hook()utility; observer telemetry schema versioningtransportdefault changed from"edit"to"auto",prefill_messages_filemoved to top-leveldm_policy: pairinggate, Feishu meeting invite parser, Telegram MarkdownV2 streaming draftsKnown minor issue (not blocking)
interactive_setup()uses removedget_env_var/set_env_var(hasexcept ImportErrorfallback, no runtime breakage)