Commit Graph

28271 Commits

Author SHA1 Message Date
pierrenode a1ddb54840 fix(gateway): tag the loop-liveness and heartbeat-poll tasks as permanent supervised watchers (#84558)
#84327 excluded _spawn_supervised's permanent watchers (session-expiry,
kanban, reconnect, the scale-to-zero watcher itself, ...) from
_scale_to_zero_has_live_background_work() via a _hermes_supervised_watcher
tag, because counting them made an armed gateway consider itself busy
forever and never go dormant.

Two more permanent, infinite-loop tasks are added to _background_tasks
OUTSIDE _spawn_supervised and were untagged:

- _loop_heartbeat_task (loop_heartbeat_forever, #66892): a `while True`
  loop started unconditionally in start() on every gateway boot. Extracted
  the inline spawn block into _start_loop_heartbeat_task() so it's
  independently testable, matching the existing _start_heartbeat_poller()
  pattern.
- _heartbeat_poll_task (_poll_loop in _start_heartbeat_poller): also a
  `while True` loop, started the first time a session registers a
  heartbeat watch, and then permanent for the rest of the process.

Because _loop_heartbeat_task starts on every boot, it alone made
_scale_to_zero_has_live_background_work() return True forever on every
armed instance, regardless of the #84327 fix -- confirmed empirically
against the real method with the exact untagged-task shape this task has.

Two new regression tests spawn each task through its real production
entry point and assert the busy check returns False; both fail against
the unfixed code (missing method / real assertion failure).

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
2026-08-20 20:24:40 +10:00
Ben Barclay 6cd1ed2e78 test(relay): rename misnamed precedence test; document the flat-key fallback nuance
test_flat_key_wins_over_subblock asserted the OPPOSITE of its name (the
sub-block wins, matching _relay_slack_extra). Rename to what it proves.
Also note in _resolve_cron_surface_mode why its fallback differs from
_relay_slack_extra's all-or-nothing sub-dict: the flat key is the legacy
staging shape, and a flat knob applies to every fronted platform, gated
only by the per-platform D6 capability check.
2026-08-20 20:13:29 +10:00
Ben Barclay a78af23cca docs(cron): document the in_channel carve-out on the mirror opt-in
_cron_mirror_delivery_enabled still promised 'cron deliveries live only
in the cron job's own session' as the unconditional default, but the
in_channel continuable surface now seeds the target session regardless
of attach_to_session/cron.mirror_delivery (the seed IS the continuation
feature, and in_channel is itself opt-in). State the carve-out where the
guarantee is documented.
2026-08-20 20:13:10 +10:00
Ben Barclay 162b23c3e2 fix(relay): D6 in_channel capability gate resolves the destination platform's descriptor
RelayAdapter.supports_inchannel_continuable is a scalar adopted from the
PRIMARY identity's handshake descriptor, but one RelayAdapter fronts N
platforms and the connector advertises the bit per platform. Reading the
scalar for every logical platform both leaked a Slack-primary True onto
other fronted platforms (activating the flat surface their descriptor
never advertised) and suppressed a non-primary platform's advertised
True (forcing thread mode on capable Slack behind a Discord primary).

Add supports_inchannel_continuable_for_platform(platform): resolves the
platform's own negotiated descriptor via descriptor_for_platform (the
same Phase 1.5 seam max_message_length uses), scalar fallback only when
the per-platform descriptor is unavailable. The scheduler's D6 gate
prefers the query when the adapter provides it; native adapters keep
the class-attribute path byte-identically.

Tests: two-platform descriptor matrix (primary-True no-leak,
non-primary-True honored, unknown-platform scalar fallback).
2026-08-20 20:12:52 +10:00
Teknium d29f76c70e fix(bot-mode): group chat no longer doubles into the Bots pane beside its main tab (#89788 follow-up)
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.

- $groupMainTabsRev atom shadows tab-map membership; all mutations go
  through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
  in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
  desktops without the main-window door (and a throwing door) still get
  the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
  (fails on the old ordering — sabotage-verified), and rev bumps on tab
  open/close.
2026-08-20 03:12:39 -07:00
Ben Barclay 79c39025c0 fix(relay): format hints resolve the DESTINATION platform, and stamp on send_for_platform
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.
2026-08-20 20:10:20 +10:00
Ben Barclay 20c56f82b8 fix(cron): in_channel thread-flatten uses the seed's gate (origin_target)
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.
2026-08-20 20:07:44 +10:00
Teknium e69b8e561d feat: consolidate 'hermes version' into 'hermes --version', remove the subcommand
'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'.
2026-08-20 03:05:46 -07:00
Teknium d425658d27 Merge pull request #90688 from NousResearch/feat/keyed-failure-one-shot-rescue
feat: failing keyed web backends rescue onto the keyless ring for one call, never sticky
2026-08-20 03:05:40 -07:00
Ben Barclay 8b6cf434cb fix(cron): carry Slack workspace scope_id into continuable seed keys
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.
2026-08-20 20:04:49 +10:00
Teknium ed3befaed6 ci: dispatch hammer 2 2026-08-20 02:47:38 -07:00
Teknium ebe6fd72ed ci: dispatch hammer 1 2026-08-20 02:47:26 -07:00
Teknium 1647d030cf fix(update): call out GitHub rate limiting/outage on fetch 429 instead of a generic failure
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
2026-08-20 02:45:57 -07:00
Teknium 88295f1914 ci: retrigger after incident window 2026-08-20 02:42:57 -07:00
hermes-seaeye[bot] 6f31cfad78 fmt(js): npm run fix on merge (#90690)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 09:20:15 +00:00
Teknium ef5ead576d ci: retrigger — only Label-rerun dispatched on d6129bff 2026-08-20 02:11:14 -07:00
Teknium d6129bff66 chore: retrigger CI (zero-job dispatch failure, auto-heal) 2026-08-20 02:08:27 -07:00
Riccardo Roveri 34bdf73381 chore: add contributor attribution mapping for rroveri@nvidia.com 2026-08-20 11:08:02 +02:00
Teknium fa2601c2f5 docs(desktop): troubleshooting entry for SSH host-key-changed latch 2026-08-20 02:07:10 -07:00
Teknium 6170021714 fix(desktop): remote-gateway desktop stops lying after disconnects — 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.
2026-08-20 02:07:10 -07:00
Teknium aca40d1d63 fix(sessions): error paths return non-zero exit codes (delete/rename/prune/import) 2026-08-20 02:06:05 -07:00
Teknium d1eefe6acc feat: keyed web backends get a one-shot keyless rescue on failure — never 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 #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.
2026-08-20 02:04:32 -07:00
Teknium a78211b15a fix(desktop): sort updates.ts imports for perfectionist lint 2026-08-20 01:48:05 -07:00
Teknium bd5b221a61 feat(desktop): updating now updates every target — remote backends, other 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).
2026-08-20 01:48:05 -07:00
Teknium 0a8a4cdb3d fix(backup): friendly error on unwritable output path instead of raw traceback 2026-08-20 01:47:06 -07:00
Teknium 138e482ed0 fix(config): set coerces negatives/whitespace/null and rejects malformed keys 2026-08-20 01:46:54 -07:00
Teknium 76653a8eba fix(sessions): prune/archive spare pinned sessions by default (data loss) 2026-08-20 01:46:20 -07:00
Teknium f796239c6b Merge pull request #90572 from NousResearch/feat/keyless-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
2026-08-20 01:46:16 -07:00
Teknium 3f67921fca fix(cli): /undo typo no longer quits the CLI; +3 slash-command papercuts 2026-08-20 01:46:08 -07:00
Teknium 33e64fd83c test: capture worktree messages through the _cprint route 2026-08-20 01:45:56 -07:00
Teknium 7b655fdf8d fix(cli): worktree lifecycle messages render colors instead of raw ANSI escapes 2026-08-20 01:45:56 -07:00
hermes-seaeye[bot] 27562ad5f8 fmt(js): npm run fix on merge (#90637)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 07:37:56 +00:00
Brooklyn Nicholson 90e30bdd27 refactor(desktop): one script runner into the preview guest page, not a tour-only one 2026-08-20 02:33:00 -05:00
Brooklyn Nicholson bd853747bb fix(desktop): a fresh profile follows the OS, and plugins stay behind 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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 50e2f970c8 feat(desktop): keep midnight, and retint themes that shade their accent
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson b8c6547ca4 fix(desktop): off means off when glass is turned down
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 98bac5feb4 feat(desktop): an accent picker plugin, off by default
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson be3166607e feat(desktop): glass ships on, tuned per appearance and platform
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 38f46504fd fix(desktop): the finished-session dot follows the theme
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 6dbd7d11d3 feat(desktop): re-seed any theme's accent from one colour
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.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson f298612429 feat(desktop): ship the GitHub themes, with Nous blue on top
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.
2026-08-20 02:28:24 -05:00
Teknium e7e3885113 test: pin ring entry vendor in provider-routing tests (ring rotation made direct-callable mocks stale) 2026-08-20 00:20:17 -07:00
Teknium 5c087df507 chore: retrigger CI (zero-job dispatch failure, auto-heal) 2026-08-20 00:20:09 -07:00
Teknium 4dcdc7d3a3 chore: retrigger CI (zero-job dispatch failure, auto-heal) 2026-08-20 00:19:02 -07:00
Teknium 90e477d3ed Merge remote-tracking branch 'origin/main' into feat/keyless-tavily-firecrawl-failover 2026-08-20 00:18:29 -07:00
Teknium 4ea69d9d2c feat: keyless web tier becomes a 5-vendor round-robin ring (adds Tavily, 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 #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.
2026-08-20 00:17:25 -07:00
Teknium c2f5d2da21 test: vary marathon-turn fixture args — identical calls now legitimately dedupe to stubs 2026-08-20 00:16:22 -07:00
Teknium 761990b780 feat: identical re-calls enter context as reference stubs, not duplicate payloads 2026-08-20 00:16:22 -07:00
Teknium 02274c39dc ci: retrigger after incident window 2026-08-20 00:12:18 -07:00
Teknium ad7a14a539 fix(desktop): renamed Bot Mode agents stay @-taggable by their new name
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.
2026-08-20 00:08:46 -07:00