Upgrade to Hermes Agent v0.20.5 (upstream v2026.8.19) - #29
Merged
maksym-mishchenko merged 7856 commits intoAug 22, 2026
Conversation
…ely dedupe to stubs
…ly, Firecrawl, Keenable) Fresh installs with zero web credentials now rotate web_search/ web_extract across FIVE vendors' public free tiers — Exa, Parallel, Tavily, Firecrawl, Keenable — instead of a 2-vendor 50/50 split, with next-in-line ring failover on rate limits (multi-hop until a vendor serves or the ring is exhausted; served_by marks the actual vendor). - plugins/web/keenable/: new bundled provider (search via /v1/search, fetch via /v1/fetch; keyed Bearer or keyless with the mandatory X-Keenable-Title app header). Credit: integration proposed by Ilya Gusev (Keenable) in NousResearch#49758; Free/Paid picker rows included. - keyless_mcp: tavily/firecrawl/keenable keyless search+extract wrappers, _KEYLESS_RING + per-process round-robin cursor (seeded by the random session id, advances per unpinned request), pinned-vendor entry (pin = start there; rotation off), paid-pinned vendors excluded from the ring entirely. - Tavily/Firecrawl providers route keyless traffic through the ring; both are now default-on ring members (no longer selection-gated). - web_tools/registry: keenable in backend sets, auto-detect, availability probes; _keyless_preference() delegates to the ring cursor. - KEENABLE_API_KEY in OPTIONAL_ENV_VARS; docs updated (ring semantics). Live E2E: all 10 vendorXcapability paths (5 search + 5 extract) served real results keyless; rotation cycled all five vendors over 5 dispatch calls; double-throttle failover walked exa->parallel->tavily.
…irecrawl-failover
…made direct-callable mocks stale)
The bundled skins were an ad-hoc set that had drifted from anything recognisable. They are now forks of the VS Code themes people already know, produced by the repo's own marketplace converter rather than transcribed by hand, so each palette is byte-identical to what installing the extension would give you. `nous` keeps GitHub's chrome and carries the brand blue as its accent. Two seeds, one colour: `#0053fd` reads at 5.4:1 on the light sidebar but only 3.6:1 on the near-black dark one, so dark carries `#4a84fe` — the same hue at 263°, lifted to clear AA at 5.9:1. Everything else in both palettes is upstream's, and a test holds that line. `github` ships alongside it, unmodified, so the original stays available on its own terms instead of only existing as the thing nous diverged from. Catppuccin, Everforest and Solarized join them; the skins nobody could name are retired, with `midnight` folded into the retired list so anyone sitting on it lands on nous rather than a dead name.
A palette's accent is not one value, it is a family: the seed plus the soft surfaces mixed from it — seven slots per appearance in nous, all derived from one colour. `retintTheme` moves the whole family at once, reusing the converter's own mix ratios so re-seeding a theme with its existing accent returns the identical object. The colour work this needed is the interesting half. Mixing toward white in gamma-encoded sRGB bends hue: a saturated blue lands 7.6 degrees violet of where it started, which is how a clean blue accent produced a lavender selection row. `mixOklab` holds the hue and moves only chroma and lightness. `ensureContrastOklch` adapts a seed for an appearance that cannot carry it by walking lightness rather than blending toward white, which would gut the chroma and wash the brand colour out. `readableOn` picked text colour from a luminance threshold, and got five shipped accents wrong in the direction that matters — white on GitHub's own dark green measured 3.29:1, below AA, where near-black measures 5.50:1. It now measures both candidates and takes the better one.
Every other dot in the set reads a token; unread was a hardcoded `emerald-500`. On a blue theme that left eight green marks down the sidebar fighting the palette around them. It now paints `--ui-success`, a success green rotated part of the way toward the accent along the shortest hue arc. Partway rather than all the way, because landing on the accent would make "finished" and "running" the same colour. The default costs nothing by construction: emerald sits at 162 degrees and GitHub green at 148, so a quarter rotation moves the dot about three degrees. The work only happens when the accent is genuinely far away, which is the case that was clashing.
Translucency was one number serving both appearances and both platforms, resting at zero. A lever that starts at zero is a feature nobody finds, and one number cannot serve four situations: a tint that reads as a whisper over a dark palette is a milky sheet over a light one, and the same numbers that read as frost on macOS vibrancy read as a washed sheet over Windows acrylic, which composites its own tint in DWM before the page is drawn. So the state splits. `mode` stays global — clear versus glass is a choice about the window, not the palette — while the values resolve through a ladder, per key: the appearance you are looking at, then a shared base, then the platform default. Tuning light mode stays in light mode; an untouched dark keeps inheriting. A v1 state lands in base, so a window someone already tuned crosses the upgrade with exactly what was on screen. Main reads the same defaults at window creation, because a window born opaque cannot reliably be swapped to glass afterwards. The chat backdrop goes off by default in the same pass: it was competing with the glass field for the same surface.
Finding a colour by hex is guesswork; finding one by eye needs a picker that does not lie about where you will land. HSV crushes the whole blue family into a narrow band of its hue rail, so dragging "to blue" puts you on pure sRGB blue, which reads violet — every blue that actually looks blue lives in a few degrees you cannot reliably hit there. This one is OKLCH. The hue rail is perceptually even and previews the current colour at every hue rather than showing a generic rainbow, and the field is a canvas drawn per-pixel through the real conversion, so its curved edge is the true sRGB gamut boundary — every pixel is a colour the display can show. Dragging repaints the whole app against the real derivation. It ships off (`defaultEnabled: false`) and holds no persisted state: the override clears on dispose, so turning the plugin off returns every surface to the authored theme rather than stranding a colour with no control to clear it. The retint itself stays in core, where Appearance settings and the command palette can reach it.
The light default carries a single point of fade so the window edge reads as glass rather than as paint. That point followed anyone who dragged the tint to zero, leaving a window that asked to be opaque sitting at 0.9999. Fade now applies only while glass is actually active, not merely selected.
Midnight is monotone in a way none of the other skins are, and it turns out to be a good test of the retint: its ring is `#8b80e8` under a `#ddd6ff` primary — the same violet at a different lightness, not a repeat of one hex. Matching accent slots by exact equality with the primary left that ring behind, so re-seeding produced a half-retinted theme with a purple ring under a teal accent. Slots now join the family by HUE, within a tolerance, and each keeps its own lightness and chroma when it moves. A theme that deliberately runs a deeper ring keeps that relationship instead of being flattened onto one colour. Near-greys are excluded by chroma rather than hue, so mono's neutral ring still stays exactly where its author put it.
… the SDK Two things a genuinely fresh instance surfaced that no existing profile could. The renderer's mode fell back to `light` when nothing was stored, so a dark-mode desktop opened a white window on first launch. Main already defaulted its own themeSource to `system`, so the two disagreed at boot — and once translucency became per-appearance it also handed those users light's much heavier tint, tuned for a bright desktop they don't have. Both the normalizer and the SSR fallback now say `system`; an explicit choice still wins. The accent plugin reached straight into `@/components` and `@/themes`, which the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk` and nothing else, so the app can move its internals without breaking them. The fix is to widen the SDK rather than exempt the plugin — it now exports the OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any plugin can derive a palette instead of hardcoding one.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…tavily-firecrawl-failover feat: keyless web tier is now a 5-vendor free rotation (Exa/Parallel/Tavily/Firecrawl/Keenable) with ring failover + honest doctor readiness
…ther gateways, and the app itself Remote-mode installs had every update affordance (About panel Update now, ⌘K Update Hermes, the update-ready toast) pointed at the BACKEND only, so users updated their VPS forever while the desktop app itself sat weeks stale — with no signal it was behind (the skew warning only fired the other way). Reported by Santiago Sarceda: mac app on v0.20.0 kept repro'ing UI bugs fixed on main because 'update' never touched the app. - store/updates.ts: applyEverythingUpdate() orchestrates all targets — active backend first (detailed progress), every other eligible registered gateway via the existing Electron fan-out (cloud rows skip), the client LAST (its apply relaunches the app). startActiveUpdate/ requestActiveUpdate route through it whenever more than one update target exists; single-machine installs keep the one-button flow. - After ANY successful backend update, the client version is re-checked and a one-click 'Update desktop app' warning fires if the GUI is still behind — the reverse-skew signal that didn't exist. - electron: hermes:connections:update-all accepts optional excludeIds so the flow doesn't double-dispatch the active backend / local runtime. - i18n: 7 new updates.* keys across en/zh/zh-hant/ja/ar. - docs: desktop.md Updating section + multi-connection guide. - tests: 10 new cases (gating, ordering, exclusions, failure isolation, memoization, nudge on/off).
…ever sticky When the chosen/keyed backend fails a web_search or web_extract call (bad key, upstream outage, 5xx, raised exception), that single call retries on the keyless free-tier ring instead of erroring. The next call attempts the chosen backend again — no sticky failover, no state. Resolves the keyed half of NousResearch#78984/NousResearch#32159 (keyless half landed in the ring PR). - tools/web_tools.py: _rescue_eligible (keyed ring vendors + non-ring backends eligible; keyless-mode calls excluded — they already walked the ring), _rescue_search/_rescue_extract (search annotates rescued_from + backend_error naming the original failure and the retry-next-call semantics; extract rescues only whole-batch failures, partial failures pass through untouched; rescue failure preserves the ORIGINAL backend error with the rescue note appended) - both dispatchers wrap the provider call: failure-results AND raised exceptions rescue; ineligible paths re-raise unchanged - web.keyless_rescue config key (default true; implicitly off when keyless_fallback is off); docs updated Live E2E: keyed Tavily with an invalid key 401'd and the call was served by the real ring with the rescue annotation; a second call re-attempted Tavily first (statelessness proven); whole-batch extract rescue returned real page content. 13 new tests; 67 green across the keyless suites.
…roster survives outages, spawn failures log, host-key change stops the retry wall Three fixes from one remote-gateway (VPS) debug bundle, all live-reproduced and re-verified on a headed Electron seat via CDP: - Bots roster no longer shrinks during a gateway outage: source enumeration is bounded (10s/source instead of wedging the roster IPC >30s behind a dead dial) and a bounced remote source keeps painting its last-known profile list (was SSH-only), so 4 bots never show as 2 mid-outage. - Pool backend spawns that die before the child exists (forced-local spawn of a profile that only exists on the remote) now log the failure to desktop.log, and the profile-exists guard runs BEFORE the Starting line — no more orphaned no-READY/no-exit spawn bursts in bundles. - An SSH host-key change (VPS reinstall) is classified terminal like a reauth rejection: it latches, the boot-failure overlay shows the ssh-keygen -R guidance, and the renderer stops the infinite boot-retry loop (one bundle had 157 consecutive failures over 2.5h). Reset/repair/ apply-config clear the latch; live-verified Retry-after-fix boots clean.
…cate + prefix-scan union
…eway tests tests/gateway/conftest.py already installs a comprehensive telegram mock at collection time (line 330), before any test module's imports run. The per-file copies were fully redundant — each was a simpler subset (plain strings, setdefault, fewer error classes) of the conftest version (which uses _fake_str_enum for PTB-faithful StrEnum semantics, sys.modules overwrite to win over partial/broken imports, and a full error hierarchy including BadRequest, Forbidden, RetryAfter, Conflict, InvalidToken). Removed: function def + module-level call + now-unused imports (sys, MagicMock where no longer referenced) + dangling comment blocks that referenced the deleted mock, in 27 test files. Left untouched: tests/gateway/conftest.py (canonical source) and tests/e2e/conftest.py (separate conftest tree that may run in isolation). Found by /simplify-code review of PR NousResearch#90560.
…mimo-v2.5-free 429 every non-opencode client Live verification (2026-08-21): big-pickle and mimo-v2.5-free return 429 FreeUsageLimitError for ANY User-Agent except the opencode CLI's own 'opencode/latest' — same IP, no cooldown effect, while the other six free models serve our honest HermesAgent UA freely. Hermes sends deliberate attribution headers and does not impersonate other clients, so these two models are broken for our users by policy on OpenCode's side; delist them rather than ship dead picker entries. - opencode-free catalog: 8 -> 6 models (both curated lists) - plugin default_aux_model: big-pickle -> laguna-s-2.1-free (fastest non-gated free model) - keyless predicate keeps big-pickle (it IS free-tier; correct routing if a user enters it manually — they get the relay's own 429, not our 401) E2E: picker shows 6, laguna aux default completes a keyless agent turn.
…upe-telegram-mock refactor: remove 19 duplicate _ensure_telegram_mock() copies from gateway tests
…arch#53902, NousResearch#73082) A respawned backend re-mints runtime ids, so a pre-reconnect busy state never receives its terminal busy:false publish and its session stayed in $workingSessionIds forever - the sidebar running arc and agents-panel 'running' chrome lied for hours after the turn ended (the stale-flag half of NousResearch#53902/NousResearch#73082; the CSS cost half landed in NousResearch#91383). reconcileBusyStatesOnReconnect() downgrades busy/awaitingResponse states through publishSessionState (watchdogs disarm, stall hints drop, settle/ unread bookkeeping stays consistent), scoped by event-source: the primary reconnect touches only scope-less runtimes, a secondary (registry) reconnect touches only its own connection's. needsInput survives - a blocking prompt is the user's to answer. A genuinely live turn re-asserts busy on its next post-reconnect event, so the worst case is one arc blink. Regression tests proven by sabotage run (neutered reconcile -> 5/6 fail).
…condary Static gateway.ts -> session-states.ts import closed a module cycle that left $activeGatewayProfile undefined at session-states init (TypeError: Cannot read properties of undefined (reading 'get')) — the CI red across all three UI shards. Dynamic import defers the edge past module init; reconcile semantics unchanged. Proven by re-adding the static edge: hud/pet suites reproduce the exact CI failure.
…ocks
Test harnesses that vi.mock('@/hermes') without setApiRequestProfile make
the session-states transitive graph unloadable; the deferred reconcile
import then rejected unhandled and failed unrelated suites in shard 2.
Catch and skip — the production graph always loads.
… phase in every update Phase 2 core slice of NousResearch#91277: the updater now knows WHAT it is operating on before it mutates anything. - hermes_cli/update_inventory.py (new): side-effect-free runtime inventory — install kind via detect_install_method (git / docker / nix / apt, updatable-in-place or not, with the correct external update command for image/package-managed installs), all profiles, every live gateway with its supervisor (systemd / launchd / manual via the fleet-wide _get_service_pids), running code_sha/code_version from the NousResearch#91283 gateway_state.json stamps, and the restart mechanism each runtime will get. - hermes update --plan: prints the plan and exits; runs BEFORE the docker/nix refusal gates so image-managed installs get a useful 'not updatable in place + right command' report instead of a bare refusal. Read-only, safe on a live fleet. - Every real update run now records the pre-update plan in its receipt ('plan' key) and prints a one-line fleet summary, so post-mortems can compare what the update SAW against what it did. - Docs: updating.md (--plan section + receipts/fleet-check section), cli-commands.md (flag row + receipts behavior bullet). - 11 tests: two-profile fleet classification, docker not-in-place, dead-PID exclusion, PID-file fallback dedupe, all-probes-fail never-raises, JSON round-trip for the receipt, print output shapes, receipt integration.
Prong A of the wide-recalc fix (FINDINGS.md round-4 protocol): read status in leaf components inside message content, hoist MessagePrimitive.Parts so status flips cannot re-render the parts subtree. Behavior-identical; invalidation scope only. The third primary edit -- dropping the data-streaming root attribute -- is NOT in this commit. The design doc calls it dead based on a CSS grep, and that grep is correct (every [data-streaming='true'] rule targets [data-slot='code-card']). But it has a live non-CSS consumer: scripts/run-short-session-hang-repro.mjs :928 and :1023 count settled assistant rows via [data-slot="aui_assistant-message-root"]:not([data-streaming="true"]) and gate the assistant-response wait on that count growing. Deleting the attribute makes that selector match every row, so the gate would pass at stream start instead of completion. Held pending a decision rather than silently weakening the repro. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eaming leaf, root isRunning) Removes the two residual invalidators left by the previous commit. data-streaming: gone from the message root. The flag is not dead -- it is the settled-row signal for scripts/run-short-session-hang-repro.mjs -- so it moved to a permanently-mounted, display:none leaf that is a ROOT-LEVEL sibling, and the repro now matches on the descendant. Placement is load-bearing three ways: a node inside [data-slot='aui_assistant-message-content'] would steal :last-child from the stall indicator and change inter-bubble margins mid-stream (styles.css:1995-2003); keeping it mounted and toggling only the attribute keeps the per-flip write on a childless node instead of making it a DOM structure change; display:none costs no layout or paint while querySelectorAll and :has() still match it. Renamed to data-message-streaming rather than reusing data-streaming: shiki puts that exact attribute on deferred code cards, which are descendants of the message root, so a descendant-matching selector sharing the name would report any message holding a deferred code card as still streaming. root isRunning: gone from the standard path. AssistantMessage now dispatches on interAgentSender, so the collapse gate's live status subscription lives in InterAgentAssistantMessage and only the rare inter-agent case pays it. The enter animation captures its enabled flag once off the runtime, non-reactively, because use-enter-animation.ts parks the value in a ref behind a useCallback([]) identity and consults it only when the callback ref fires at mount -- a live subscription fed a value the hook already ignores. Adds inter-agent-collapse.test.tsx: the collapse gate and the marker contract both had zero coverage, and nothing in the app reads the marker, so a delete would otherwise look free and silently regress the repro's response gate. Behavior-identical; invalidation scope only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last status-dependent read at the message root, and the most expensive one: the completedText selector flipped between '' while running and a full messageContentText(content) join once settled, so every running <-> settled transition re-ran the join for the whole message AND re-rendered the root. At stream breadth N that is N joins plus N root re-renders per flip. completedText and the previewTargets memo it feeds now live in a new AssistantPreviewEmbeds leaf, mounted at the same position inside [data-slot='aui_assistant-message-content']. Verified before moving that previewTargets fed nothing else at the root -- its only consumer was its own render block. The leaf renders the same wrapper div with the same classes, or null when there are no targets, so the DOM is byte-identical; a component boundary adds no node, so unlike StreamingMarker this needed no placement care around the :first-child/:last-child rules. The '' branch is preserved deliberately: it is the streaming-side optimization that keeps the selector referentially stable so per-token flushes skip the regex scan. AssistantMessageBody now holds no status-dependent subscription at all -- what remains is messageId, hasVisibleText, isInterim and turnDurationS, none of which move on a pending flip. Adds preview-embeds.test.tsx. The embed had no coverage, and the two cases are written as a matched pair on the same selector -- present once settled, absent while running -- so neither can pass vacuously. Behavior-identical; invalidation scope only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scheduler attribution on the incident trace (FINDINGS.md round-5-Opus receipt) puts 133 of 138 wide document-scale recalcs on GlyphSpinner's ticker, and 0 of 254 cheap ones. Replacing glyph.textContent every interval is a structural text-node mutation, so each tick scheduled a style recalculation that resolved against the whole document -- with N spinners mounted in a streaming transcript, that is the incident. Every frame is now in the DOM from mount as a vertical strip, scrolled by a transform translateY keyframes animation. Transform animations run on the compositor: no JS timer, no text mutation, no per-frame style recalc, layout or schedule. The strip is N frames tall and each frame is exactly 1em, so translating -100% travels N frames; steps(N) (jump-end) samples that at 0, 1/N .. (N-1)/N, i.e. it parks on frame 0..N-1 for one interval each and wraps -- the same sequence and cadence the setInterval produced. Frame count and duration (N x interval) arrive as inline custom properties, so all 18 spinner names / 16 distinct frame-interval shapes share one keyframes rule with no generated or colliding per-variant CSS. Sizing, colour and alignment are unchanged: the outer cell keeps its exact classes, and the 1em clipping viewport is centred by the same items-center that used to centre the single glyph -- so consumer classNames that set a box (size-3, size-3.5) or a font-size still land the way they did. Gating semantics preserved, per the original "N mounted tabs each ticking burns CPU for pixels nobody can see": - kept-alive hidden tab -> data-paused -> animation-play-state: paused. Kept explicit rather than relying on the pane's content-visibility:hidden, since that containment has a runtime kill switch and older pane layers only set visibility:hidden, which does not stop an animation. - window blur / minimize / document hidden -> the strip joins the existing :root[data-renderer-animations-paused] allowlist in styles.css, driven by main.tsx's installRendererAnimationPauseState(). That is the mechanism every other continuous decorative animation here already uses, so the per-spinner createRendererLoopPauseController goes away. - reduced motion is now honoured, which the ticker never did: the blanket @media (prefers-reduced-motion: reduce) rule freezes the animation. This also makes E2E screenshots deterministic, which that rule exists for. The frames are marked aria-hidden. role="status" is a live region and the old implementation rewrote its text ~12x/second, which announced a new glyph on every tick. Tests rewritten, deliberately: all five previous cases asserted the ticker itself (vi.getTimerCount(), per-tick textContent), which no longer exists. The replacements pin what jsdom can see -- frame order, the custom properties feeding steps()/duration per variant, zero timers ever created, the hidden-tab gate, aria-hidden, and that the strip is still named in the global pause rule. The blur/minimize/document-hidden contracts now belong to that global mechanism and are covered by lib/renderer-loop-pause.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…verlay ticker Three items from the adversarial review of the compositor-only spinner. 1. COMPOSITOR PROMOTION. The keyframes travelled translateY(-100%), which resolves against the strip's own box and so makes the animation layout-dependent: instrumentation recorded a non-zero compositeFailed on 184/184 records (131072 / 131104) while a sibling transform animation using an absolute length composited clean. The travel is now calc(frames * -1 * frame-height), an absolute length for the same distance, and the strip gets will-change: transform. steps(var(--glyph-spinner-frames)) and the 1em frame metric are unchanged. The frame height is now a custom property on .glyph-spinner, used by the clip viewport, each frame box and the keyframe travel, so those three cannot drift. On the em-resolution question: the keyframes apply to .glyph-spinner__strip and nothing below .glyph-spinner declares a font-size, so the strip's em and the frame's em are the same length -- the property makes that a single declaration rather than a coincidence to re-verify. 2. SELECTION. .glyph-spinner takes user-select: none (plus -webkit-). These sit inside [data-selectable-text] subtrees, where the strip contributed all N glyphs to a transcript copy against the old implementation's one. None is right for a decorative aria-hidden element. 3. SAME-CLASS SWEEP. chat-swap-overlay.tsx ran its own 80ms setInterval + setState braille ticker -- the exact mechanism this fix removes. Its setFrame drove only the glyph (setLabel is independent), and its frame set and cadence are exactly the `braille` variant, so it now renders GlyphSpinner. `justify-start` (tailwind-merge lets the caller win) keeps the glyph left-aligned in its w-3 box as the bare span was. GlyphSpinner gains a `paused` prop for it: the overlay stays mounted through its fade-out, and the old cleared-interval behaviour was to stop animating once the swap was done. Tests: guards for the two invisible-in-jsdom regressions -- keyframes must not return to a percentage translate, and the selection guard must stay -- plus the paused prop, and a new chat-swap-overlay.test.tsx pinning that no timer comes back, the label still survives the fade-out, and the glyph freezes with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-ups on the compositor spinner and the invalidation scoping. Spinner CSS: - Clip each frame to its own box. Braille renders from a system fallback face (JetBrains Mono has no U+2800 block), whose metrics are not guaranteed to fit the 1em frame, so neighbouring ink could bleed into the viewport. - Name descendants explicitly in the selection guard. The competing `[data-selectable-text='true'] *` rule has the same (0,1,0) specificity, so relying on inheritance made the winner depend on stylesheet order. - Scope the compositor promotion to spinners that are actually running. A permanently promoted layer per parked spinner is pure memory at fan-out breadth, where many sit mounted and paused at once. - Give every var() the braille default as its fallback, so a missing custom property degrades to a working spinner rather than an invalid declaration. Spinner component: replace the bare `as CSSProperties` cast on the inline style with an exported GlyphSpinnerVars contract, so a typo in a custom property name is a compile error rather than a silently dead declaration. Assistant message: - Render the inter-agent collapse as a CHILD of the normal body instead of a competing root. The settled case previously returned a different element type than the running case, so settling unmounted the whole row and mounted a fresh one — discarding the DOM the scroll anchor held. One component, one root, children vary; the truth table is unchanged, including the collapsed row carrying no tapback listener. - Collapse AssistantStatusSlot's separate subscriptions into one selector returning a stable string. The inputs always move together on a status flip, so reading them separately just multiplied the wake-ups. - Give StreamingMarker a stable `data-slot` and assert on that rather than on `span.hidden`. Repro script: count settled rows by subtracting streaming markers from message roots instead of `:not(:has(...))`. The selector walked every row's subtree on each evaluation, inside the very latency window the probe measures. Comments: drop the stale translateY(-100%) description, name both pause triggers, replace hard-coded line-number citations with selector/symbol ones, note that only the primary window arms the renderer-pause attribute, and move the forensic trace numbers out of source comments into the PR. Delete the three tests that asserted on stylesheet TEXT. AGENTS.md bans reading source in tests outright, and they demonstrated exactly why: a var()-fallback edit that changed no rendered pixel broke one of them. Replacements that exercise the CSS in a real browser follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
…r real Replaces the deleted stylesheet-text assertions with tests that run the thing they claim to cover. e2e/glyph-spinner.spec.ts drives a real browser, where the CSS actually executes: the strip's animation resolves to steps(N) for N frames, runs infinitely, and travels a resolved length rather than a percentage (a percentage translate is layout-dependent and Chromium refuses to composite it). Both pause gates are covered — the per-spinner `data-paused` attribute and the global renderer-pause attribute that window blur / minimize / document-hidden arm — along with the layer promotion being scoped to running spinners. A sampling test confirms the transform visits a bounded number of distinct values across one cycle (steps, not a linear sweep) and that nothing mutates the DOM while it animates, which is the property the whole change exists to deliver. status-invalidation-scope.test.tsx pins the scoping itself as a render count. `useTapbackDoubleClick` is called by AssistantMessageBody and by nothing else in the tree, which makes it an exact render counter for the message root without exporting internals. A settle and a delta flush must both leave that count untouched while the leaves update. Verified by mutation: reinstating a root-level status subscription fails the settle test (2 renders where 1 is required). It also pins node identity across the settle transition, so the inter-agent collapse cannot go back to swapping element types at the message-root position and remounting the row under the scroll anchor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
…honest compositor receipt Review-round residuals: the spinner's user-select guard now beats [data-selectable-text] regardless of stylesheet order; the will-change layer hint clears under the global renderer pause and reduced motion so parked spinners hold no compositor layer; the e2e travel assertion reads the engine's keyframes (a computed transform always serializes to a matrix, so the old '%' check could never fail); inline import() type hoisted for the lint gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he remote-merge persist path
updateGroupChat's inline durable-map builder (the local-mutation persist
path) skips tombstoned rooms and carries roomId — but durableGroupChatRooms,
the SEPARATE builder persistGroupChatRooms uses for the remote-merge path
(every pullGroupChatServerState / gateway-swap sync), has neither.
Two independent gaps in the same function:
1. Tombstone resurrection. Disband sets a runtime-only tombstone
({tombstone: true, log: [], ...}) while a drive may still be mid-turn,
with no roomId. mergeRemoteGroupChatSnapshotIntoRooms spreads
...existing before its explicit field overrides (none of which touch
tombstone), so if a remote gateway hasn't received the delete yet
(plausible now that sync fans out to every reachable default-profile
gateway with independent per-connection backoff) and still has a live
copy of the room, the tombstone flag survives into the merged room.
That merged map is handed straight to persistGroupChatRooms, which
wrote it to storage because durableGroupChatRooms had no tombstone
check. On the next cold hydrate the persisted tombstone reads back as
an empty, non-tombstoned room, resurrecting the original bug
(recreating a room under the same name silently becomes "<name> 2")
through a path the earlier tombstone fix didn't cover.
2. roomId loss. mergeRemoteGroupChatSnapshotIntoRooms correctly carries
roomId into the merged in-memory room, but durableGroupChatRooms never
included it in the persisted snapshot. Every room merged in via the
remote-sync path therefore loses its immutable room identity on the
next cold hydrate (comes back with roomId: null) and falls back to
legacy name-keyed identity — breaking id-based rename/merge resolution
and member-session titling ("Group: <roomId>").
Fix: durableGroupChatRooms now mirrors updateGroupChat's inline map
exactly — skip tombstones, carry roomId.
Tests: durableGroupChatRooms unit tests for both gaps, plus an
end-to-end reachability test (tombstone -> mergeRemoteGroupChatSnapshot-
IntoRooms -> persistGroupChatRooms -> storage) proving the merge really
does forward the tombstone and the fix really does keep it out of
storage. Mutation-verified against pre-fix code (all 3 new tests fail).
Full hermes-bots plugin test suite (60+ files) green.
…free appears in /model and desktop pickers with zero setup A keyless provider has no credential to lack, but every auth-gated surface treated 'no key' as 'not authenticated', so opencode-free was invisible in /model, provider:model listing, and the desktop model pickers unless a user had unrelated OpenCode env vars set. One policy, three gates, all derived from the HermesOverlay keyless flag (NousResearch#91358): - auth.py get_api_key_provider_status: keyless providers report configured/logged_in=True with key_source 'keyless' — flows through get_auth_status to every status consumer (hermes status, dashboards, list_available_providers). - model_switch.py list_authenticated_providers: keyless overlay rows get has_creds=True before any env/pool/auth-store checks — this is the source for /model, the TUI picker, and the desktop /api/model/options payload. - inventory.py explicit-only filter (desktop chat pickers): keyless providers are kept — there is nothing to 'explicitly configure', and hiding a zero-setup provider defeats its purpose. E2E (temp HERMES_HOME, all keys stripped): get_auth_status logged_in, list_available_providers authenticated, picker row with 6 models, desktop payload default AND explicit_only both include the provider, and the full switch pipeline (parse free:x-preview-f-free → switch_model) resolves to the keyless runtime. 4 new tests.
…odels in, ox-alpha-free on Go First actioned report from the overhauled model-catalog-scout cron (2026-08-21 validation run), every item re-verified live before edit: Delisted (gone from live catalogs): - opencode-zen curated: claude-opus-4-1, qwen3.7-max, qwen3.7-plus (absent from live zen /v1/models; qwen3.7 family remains on Go) - OPENROUTER_MODELS free section: poolside/laguna-m.1:free (rotated to s-2.1/xs-2.1), tencent/hy3:free, inclusionai/ring-2.6-1t:free Added (present + verified in live catalogs): - OpenRouter free: z-ai/glm-5.2:free (256K), poolside/laguna-s-2.1:free + laguna-xs-2.1:free (262K), nvidia/nemotron-3.5-lightning:free (1M) - opencode-go curated: ox-alpha-free (Go-subscription twin of the Zen keyless Ox Alpha; keyed — Go relay 401s anonymous requests) Metadata: - DEFAULT_CONTEXT_LENGTHS: laguna-s-2.1/xs-2.1 262144; nemotron-3.5-lightning 1M (overrides the generic 131K nemotron entry); glm-5.2:free 256K (the free variant is capped below the 1M paid entry) Keyless-heal hardening (the real find): - opencode_zen_free_runtime now gates the zen/go→keyless heal on MEMBERSHIP in the verified opencode-free catalog, not the -free suffix — ox-alpha-free is a KEYED Go model despite its suffix, and suffix-based healing would have routed it to a Zen relay that doesn't serve it (verified: zen 401s 'not supported', go 401s 'Missing API key'). New regression test pins this. Fixture sweep: tencent/hy3:free catalog assertion updated (delisted slug); nous-route fixtures using hy3:free as incidental model names left alone (self-consistent mocks). model-catalog.json regenerated.
…pre-checkpoint pruning prune_pre_checkpoint_items() had a hardcoded role=='user' filter that discarded all non-user messages before a checkpoint — including Hermes' own compression summaries (role='assistant'), causing total context amnesia about past conversation summaries. The fix: - _is_summary_item delegates to the canonical agent.context_compressor.is_compaction_summary_message provenance check (not an ad-hoc heuristic) - Summaries are retained whole (never byte-sliced) within a 32k token budget - Idempotent across repeated checkpoints (dedup by identical text) - _chat_messages_to_responses_input threads item_sources (raw chat messages) through to the pruner, so it can read summary content directly from the source when the Responses conversion shape is lossy (tool-result carrier becomes function_call_output, or stale codex_message_items replay shadows merged content) Fixes NousResearch#90975. Salvage of NousResearch#90976 by @JoaoMarcos44.
Upgrade from v2026.7.20 (v0.19.0) to v2026.8.19 (v0.20.5). ~7852 upstream commits merged with fork-specific customizations preserved. Conflicts resolved (21 files): - Lock files (uv.lock, package-lock.json): took upstream - Langfuse plugin: took upstream (now includes redaction + boundary hooks) - Bitwarden secrets: took upstream (now has encrypted cache, addresses fork's plaintext concern) - LSP manager, gateway base, status, tools: took upstream - Tests: took upstream versions (fork-specific tests survived unmodified) Fork customizations verified present: - Telegram polling conflict recovery (capped at MAX_CONFLICT_RETRIES=5 in upstream) - Disk-cleanup OSError hardening (preserved in merge) - Kanban diagnostics module (fork-only, no conflict) - Langfuse telemetry/redaction (now upstream-native) - Gateway health/status endpoints (upstream-evolved) - LSP idle reaper tests (preserved) - Security audit startup (preserved) - Gitleaks/pre-commit (preserved) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Upstream renamed _reaper_task → _idle_reaper_task, _reap_idle_clients → _reap_idle_once, removed REAPER_INTERVAL constant (interval now derived from idle_timeout). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ution The merge resolution at line 45 of gateway/platforms/webhook.py dropped 'List' from the typing imports, causing a NameError at class definition time. This broke all 7 webhook test files (64 tests). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb6b41b2-2e0d-4a7c-92a5-a5a73f13a767
The upstream compute_task_diagnostics() accepts graph= but our fork's version lost it during merge. This caused all kanban dashboard tests to fail with TypeError. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb6b41b2-2e0d-4a7c-92a5-a5a73f13a767
maksym-mishchenko
deleted the
maksym-mishchenko-upgrade-hermes-v2026-8-19
branch
August 22, 2026 11:43
maksym-mishchenko
added a commit
that referenced
this pull request
Aug 22, 2026
* fix(deps): regenerate uv.lock for v0.20.5 pyproject.toml changes The v0.20.5 upgrade (PR #29) added langfuse to pyproject.toml but did not regenerate uv.lock, causing `uv lock --check` to fail and all Python CI jobs (`uv sync --locked`) to abort before running any tests. Root cause: pyproject.toml declares langfuse>=4.14.3,<5 but uv.lock was missing the corresponding resolution. This single desync cascaded into 16 job failures (12 test slices, e2e, macOS, Windows, lock check). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(ci): repair test failures from v0.20.5 migration - hermes_cli/managed_uv.py: add missing `import shutil` (NameError at line 423) - tests/skills/test_hermes_agent_session_store_docs.py: point STATE_PY to hermes_state_common.py where DDL now lives - skills/autonomous-ai-agents/hermes-agent/SKILL.md: add session store query docs (started_at/ended_at columns, no missions table warning) - hermes_cli/kanban_diagnostics.py: implement _rule_review_dependency_deadlock diagnostic (tests expected it but implementation was missing) - gateway/status.py: hold supplemental POSIX flock on scoped lock files so external processes can detect contention via nonblocking flock Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(lint): add encoding to open() in scoped lock flock Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(kanban): read block reason from events not task field The block reason is stored in the 'blocked' event payload, not as a field on the Task dataclass. Updated _rule_review_dependency_deadlock to scan events for the review-required reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(kanban): use Event.kind field name (not event_type) The Event dataclass uses 'kind' for the event type column. The rule was checking the non-existent 'event_type' attribute, so it never matched the 'blocked' event. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Upgrades the fork from v2026.7.20 (v0.19.0) to v2026.8.19 (v0.20.5) — ~7852 upstream commits merged.
Conflicts Resolved (21 files)
All resolved by taking upstream, since upstream incorporated or superseded our fork patches:
bws_cache.enc.json), addressing our plaintext security concern (PR#10)MAX_CONFLICT_RETRIES=5cap (PR#11)Fork Customizations Preserved
agent/secret_sources/bitwarden.pyplugins/observability/langfuse/__init__.pygateway/status.pyplugins/platforms/telegram/adapter.pyplugins/disk-cleanup/__init__.pytests/agent/lsp/test_idle_reaper.pyhermes_cli/kanban_diagnostics.pyhermes_cli/security_audit_startup.pytests/test_session_retention_observability.py.gitleaks.toml,.pre-commit-config.yamlTest Results
All fork-specific regression tests pass:
tests/test_bitwarden_secrets.py✅tests/hermes_cli/test_kanban_diagnostics.py✅tests/agent/lsp/test_idle_reaper.py✅ (updated for upstream API)tests/test_session_retention_observability.py✅tests/plugins/test_langfuse_plugin.py✅tests/plugins/test_disk_cleanup_plugin.py✅Version
pyproject.tomlversion: 0.20.5Rollback