Skip to content

Upgrade to Hermes Agent v0.20.5 (upstream v2026.8.19) - #29

Merged
maksym-mishchenko merged 7856 commits into
mainfrom
maksym-mishchenko-upgrade-hermes-v2026-8-19
Aug 22, 2026
Merged

Upgrade to Hermes Agent v0.20.5 (upstream v2026.8.19)#29
maksym-mishchenko merged 7856 commits into
mainfrom
maksym-mishchenko-upgrade-hermes-v2026-8-19

Conversation

@maksym-mishchenko

Copy link
Copy Markdown
Owner

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:

Fork Customizations Preserved

Customization Status Evidence
Bitwarden secret sourcing (no plaintext) ✅ Superseded by upstream encrypted cache agent/secret_sources/bitwarden.py
Langfuse telemetry + redaction ✅ Upstream-native (23 redaction refs) plugins/observability/langfuse/__init__.py
Gateway health/status ✅ Upstream-evolved gateway/status.py
Telegram polling recovery cap ✅ Upstream has MAX_CONFLICT_RETRIES=5 plugins/platforms/telegram/adapter.py
Disk-cleanup OSError hardening ✅ Preserved in merge plugins/disk-cleanup/__init__.py
LSP idle reaper ✅ Test updated for upstream API tests/agent/lsp/test_idle_reaper.py
Kanban diagnostics ✅ Fork-only, no conflict hermes_cli/kanban_diagnostics.py
Security audit startup ✅ Fork-only, preserved hermes_cli/security_audit_startup.py
Session retention observability test ✅ Preserved tests/test_session_retention_observability.py
Gitleaks + pre-commit ✅ Preserved .gitleaks.toml, .pre-commit-config.yaml

Test 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.toml version: 0.20.5

Rollback

git revert --mainline 1 <merge-commit-sha>

teknium1 and others added 30 commits August 20, 2026 00:12
…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.
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.
teknium1 and others added 26 commits August 21, 2026 03:51
…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
maksym-mishchenko merged commit ad6ab37 into main Aug 22, 2026
44 of 63 checks passed
@maksym-mishchenko
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>
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.