Two gaps in the block-formatting hint stamping:
1. Wrong descriptor: _format_hints gated on self.descriptor — the PRIMARY
identity's scalar — while one RelayAdapter fronts N platforms. A
Slack-primary adapter stamped Slack hints onto known Discord chats; a
Discord-primary adapter suppressed hints for Slack chats whose own
negotiated descriptor advertised the bit. Resolve per destination:
send/edit use _descriptor_for_chat (the same seam max_message_length
already uses) plus the chat's logical platform for the config
sub-block; the knob lookup is now per-logical-platform
(platforms.relay.extra.<platform>.*) instead of hardwired to slack.
2. Missing lane: send_for_platform — the scheduled/persisted-home lane
(gateway/delivery.py), i.e. the CRON delivery path, the flagship
consumer of the in_channel brief — never stamped hints at all. Stamp
there too, resolving descriptor_for_platform(logical) off the
transport; the scalar descriptor is used only when it belongs to that
exact platform (fail closed).
Tests: Slack-primary/Discord-chat no-leak, Discord-primary/Slack-chat
still-stamps, send_for_platform stamps for capable platform and stays
clean for incapable — all against a two-platform negotiated-descriptor
transport. Existing single-platform suite unchanged and green.
The seed was decoupled from the mirror opt-in (in_channel is the
continuation surface regardless of attach_to_session), but the
thread-id-clearing gate above it still read mirror_this_target. With the
advertised default config (attach_to_session=false, cron.mirror_delivery
unset) and an origin carrying a real thread_id, the brief kept delivering
INTO the origin thread while the flat (thread_id=None) session got
seeded — brief and continuation surface in different places, so a plain
reply never saw it.
Flatten on the same gate as the seed: origin_target (with the existing
live_adapter_ready guard). Fan-out/broadcast targets are unaffected.
Test drives _deliver_result with a thread-carrying origin and default
knobs, asserting on the routed DeliveryTarget.thread_id — RED on the old
gate, GREEN now.
'hermes --version' (and -V) now prints the full version report — banner
version line with upstream SHA, install directory, authoritative install
method, Python and OpenAI SDK versions, and update status — making the
separate 'hermes version' subcommand redundant. The subcommand is removed.
- _startup_fast.print_fast_version_info() is now THE canonical version
printer: static lines print instantly from stdlib probes, then the
banner label, install-method resolver, and update check lazy-import
after the first line is on screen (each degrades gracefully).
- main.py _print_version_info() delegates to it (used by /version in the
CLI chat surface and the --version flag path); the old duplicate
implementation is deleted.
- hermes_cli/subcommands/version.py removed; parser wiring, subcommand
sets, console-engine extraction entry, and tests updated. Hermes
Console keeps a 'version' command wired to the shared printer.
- Termux fast paths now include update status too (previously
check_updates=False).
- Docs/i18n, CONTRIBUTING, SECURITY, and nix checks updated to
'hermes --version'.
build_session_key embeds the workspace segment (scope_id) in every Slack
dm/group/thread key, but both cron seed helpers built their SessionSource
without it: the seeded row keyed agent:main:slack:dm:<chat>:<thread> while
a real scoped reply keys agent:main:slack:dm:<team>:<chat>:<thread> — a
row no reply ever resolves to. DMs were rescued only incidentally by the
legacy-key claim-once migration; scoped channels/threads got continuation
amnesia, and identical channel ids in two workspaces could collide.
Capture HERMES_SESSION_SCOPE_ID into the cron origin (_origin_from_env —
the session-context var async_delegation already snapshots), add scope_id
to _seed_cron_thread_session/_seed_cron_channel_session, and pass the
origin's scope at all three seed call sites.
Tests: scoped dm-thread / channel-thread / flat-channel seed-vs-reply key
equality through the real build_session_key, plus a two-workspace
non-collision guard.
A GitHub-side HTTP 429 during 'hermes update' printed only
'Failed to fetch updates from origin.' — and the curl
'unable to access ... returned error: 429' shape even matched the
network-error branch, blaming the user's connection for a GitHub
outage.
- new _classify_fetch_failure(): 429/rate-limit -> 'GitHub is rate
limiting requests or having an outage — try again in 5 minutes';
5xx -> outage message with githubstatus.com; ordered BEFORE the
generic 'unable to access' network check
- both fetch-failure sites (update apply + --check) now share the
classifier via _print_fetch_failure(), and both always print the
first raw stderr line so the wire error stays diagnosable
- tests: classifier matrix + E2E against a live local HTTP server
returning 429 through real git
Fixes#89287
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.
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 #78984/#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.
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).
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.
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 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.
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.
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.
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.
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.
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.
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 #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.
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name)
changed the roster row but not what the user could @-tag it with — mentions
still only resolved the original profile handle, and the composer
autocomplete never offered the new name.
- mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for
the taggable forms a friendly name yields (slugged + collapsed), with
reserved tokens (hermes/default/everyone/all/user) excluded so a rename
can never hijack them.
- resolveRosterMentions() and parseGroupChatMentions() accept the friendly
forms alongside the profile name/handle (both keep working).
- Composer @ autocomplete (global provider + group-room popover) inserts
the renamed tag and prefix-matches on tag, handle, and display name.
- Mention middleware's cold-cache fallback now runs the same resolver
instead of a bare-names-only parse, so renamed tags resolve there too.
- durableGroupChatMembers persists title/display_name so renamed-tag
mentions survive connection switches in cross-machine rooms.
- Docs: bot-mode.md documents renamed tags.
Stacking was the ladder's last rung, but a tile can be dragged far narrower
than the stacked controls row costs. Two more width stages, from the same
measured-width engine: under 260 the three voice toggles fold into the one
menu HUD mode already uses, and under 180 the model pill and the menu drop
too — input and Send, nothing else, down to the 80px pane floor. The model
pill also shrinks and truncates between stages instead of holding its width,
and the metrics hook returns one ComposerFit so a resize re-renders only when
a stage actually flips.
The surface is a grid that never declared a column, and an implicit auto
column sizes to its items' min-content. The coding status row (branch, PR
chip, worktree path, counts — none of it wrapping) out-measured narrow panes
and silently set the track wider than the surface; every w-full child laid
out against that phantom width and overflow-hidden clipped the right edge,
send button first. grid-cols-[minmax(0,1fr)] pins the track to the surface.
Review follow-up on the salvaged #89444:
- Warn fires only from the conversation-loop pre-API site, reusing the
unconditionally computed request_pressure_tokens (zero marginal cost,
covers turn-start AND mid-turn growth) — drops the duplicate every-turn
estimate the turn-context block paid.
- Turn-context block now only RE-ARMS the dedup once the session is back
under the window, so warn -> /compress -> regrow warns again (the dedup
was previously never cleared with compression disabled).
- Char pre-check treats non-string (multimodal) content as over-gate —
len() of a part list defeated the 20k char floor (probe: 10 'chars' vs
~70k real tokens) — and compares against the window, not a flat 20k.
- Deletes the unreachable get_model_context_length fallback from both
sites (context_compressor always exists; its context_length property
hard-floors positive; the fallback would have been a synchronous
network probe mid-turn that also bypassed config overrides) and the
undeduped inline _emit_warning fallback (third copy of the message).
- Tests bind the PRODUCTION warn/clear methods (previously a verbatim
fake reimplementation left them uncovered) and add dedup, re-arm,
no-rearm-while-over, and multimodal-gate coverage.
When compression is explicitly disabled (compression.enabled: false), conversations can grow past the model's context window across hundreds of messages (e.g., 824 messages / 460K+ tokens in #89297). Serializing massive JSON payloads repeatedly under memory-constrained environments leads to swap thrashing (STAT=U) and unhandled provider errors.
Add a pre-flight uncompressed context overflow guardrail in build_turn_context and a deduped _warn_uncompressed_context_overflow method on AIAgent to alert users to run /compact or enable compression before unmanageable payloads freeze the process.