Skip to content

chore(contributors): map tbsonline@protonmail.com -> jasoisjaso - #77600

Merged
kshitijk4poor merged 1 commit into
NousResearch:mainfrom
kshitijk4poor:chore/attrib-jasoisjaso
Aug 3, 2026
Merged

chore(contributors): map tbsonline@protonmail.com -> jasoisjaso#77600
kshitijk4poor merged 1 commit into
NousResearch:mainfrom
kshitijk4poor:chore/attrib-jasoisjaso

Conversation

@kshitijk4poor

Copy link
Copy Markdown
Collaborator

One-file attribution mapping for the upcoming #59077 salvage (jasoisjaso's commits ride at the base of the salvage branch; this makes release attribution resolve their email to the GitHub login).

Needed for the NousResearch#59077 salvage (batch compression-tip row fetch) so
release attribution resolves the contributor's commits.
@kshitijk4poor
kshitijk4poor merged commit abe7f78 into NousResearch:main Aug 3, 2026
2 checks passed
@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation labels Aug 3, 2026
vashkartik added a commit to vashkartik/hermes-agent that referenced this pull request Aug 4, 2026
…-commit only) (#21)

* feat(mcp): add fingerprint-keyed on-disk MCP tool-schema cache

Stores per-server tool manifests in ~/.hermes/mcp_schema_cache.json so
tools can be registered into the agent snapshot without spawning the
stdio child at startup. Entries are keyed by server name plus a
fingerprint of the connection-defining config (command/args/url/
transport/tool filters), so any config change invalidates the entry.

Extracted from #56832.

* feat(mcp): lazy server startup from schema cache (design from #56832)

Wires the fingerprint-keyed schema cache (previous commit, @Vansh5632's
design from #56832) into the startup path, re-derived onto main's
current connect machinery:

- register_mcp_servers: servers with mcp_servers.<name>.lazy=true whose
  config fingerprint matches a valid cache entry register tools from
  cache WITHOUT spawning; miss/stale falls back to eager connect.
- First tool use routes through _ensure_lazy_server_connected, which
  composes with the connect cooldown (#50394) and _server_connecting
  dedup rather than duplicating the connect path.
- resource/prompt utility handlers (list_resources/get_prompt) also
  connect-on-first-use — closes the gap flagged in the original
  sweeper review.
- Write-through: a live connect refreshes the cache entry.

Config gate is per-server, default OFF, matching the
idle_timeout_seconds key pattern. 24 lazy/cache tests + 440 mcp-wide
green; mutation-checked (cache-read disabled -> registration test
fails; connect bypassed -> 3 first-use tests fail).

* polish(mcp): simplify-pass folds on the lazy-startup salvage

Five review findings folded:
- schema cache writes via utils.atomic_json_write (fsync; was bare
  tmp+replace), file moved to cache/mcp_schema_cache.json with 0o600
  (sibling precedent: registry discovery cache)
- phantom-tool reconciliation: after a lazy server's first-use connect,
  cached tools the live server no longer offers are deregistered (were
  permanent registry ghosts burning circuit-breaker strikes on every
  'Unknown tool' round-trip); stale fingerprint logged
- cache-load path now runs _scan_mcp_description like the eager path
  (cache file is user-writable JSON; defense-in-depth)
- write-through skips the disk rewrite when the entry is unchanged
  (a flapping stdio server was rewriting byte-identical JSON per
  revival)
- _lazy_server_fingerprints no longer write-only dead state (consumed
  by the reconciliation logging)

444 mcp tests green (440 pre-fold + 4 new guards); phantom-dereg and
write-skip mutation-checked.

* fix(stt): thread confidence thresholds into faster-whisper's own gate (#74178)

build_local_transcribe_kwargs read stt.local.no_speech_prob_threshold /
stt.local.logprob_threshold only for Hermes' post-filter
(_is_hallucinated_segment). faster-whisper's model.transcribe() never
received them, so its internal defaults (no_speech_threshold=0.6,
log_prob_threshold=-1.0) always applied and silently dropped
low-confidence segments before they reached the post-filter — making
those config knobs dead for the first gate.

Non-English speech decodes at a lower avg_logprob, so the English-tuned
defaults discard whole utterances (empty transcript despite correct
capture and language detection). Map the same config values through to
model.transcribe() so both gates stay in sync and the knobs work.
Defaults are unchanged, so behavior is identical unless a user tunes them.

Fixes #74178

* chore: add contributor email mapping for wangyunyou

* fix(credential_pool): check copilot suppression before token exchange

The copilot branch of _seed_from_singletons ran the suppression gate
_after get_copilot_api_token(), which retries the network exchange 3x
with backoff (~13s worst case). A source the user already suppressed
(hermes auth remove copilot gh_cli) still burned the full exchange dead
time on every pool load — model picker open, /model, agent startup —
only to have the entry discarded afterwards.

Move the _is_suppressed() gate ahead of the network call, matching the
early-gate pattern every other singleton branch uses. Suppressed copilot
sources now skip the exchange entirely. Measured: model.options payload
build drops from ~13s to ~0.2-0.4s for a user with copilot suppressed.

Add regression test test_load_pool_skips_exchange_for_suppressed_copilot
asserting the exchange is never invoked for a suppressed source.

* perf(credential_pool): skip gh subprocess when all copilot sources suppressed

The all-sources suppression gate now runs before resolve_copilot_token(),
which shells out to `gh auth token` (~30ms) on every pool load. A user
who suppressed every copilot source (hermes auth remove copilot gh_cli
suppresses gh_cli + all env variants) still paid the subprocess spawn on
every load — model picker open, /model, agent startup.

Enumerate the same source space credential_sources._remove_copilot_gh
suppresses and bail before any work when all are suppressed. Measured:
model.options payload build drops from ~0.46s to ~0.26s cold for an
all-suppressed user; resolve_copilot_token() is no longer called at all.

* fix(credential_pool): classify copilot sources by exact match

Review fold on the #76341 salvage: the substring test ('gh' in
source.lower()) classified GH_TOKEN and GITHUB_TOKEN as gh_cli, so a
user's env-var-specific suppression was silently bypassed (and
suppressing gh_cli silently dropped env tokens). Pre-existing bug on
main, but the PR's early gate makes the classification decide whether
the exchange runs at all. Match resolve_copilot_token's exact
'gh auth token' sentinel instead.

Adds 3 regression tests: env-var suppression gates the exchange,
gh_cli suppression doesn't swallow env tokens, all-sources suppression
skips the resolve subprocess entirely. Also corrects the ~13s comment
(actual worst case ~35s: 3x10s timeouts + 4.5s backoff).

* chore: add contributor email mapping for szzhoujiarui

* chore: map rodboev and MaartenDMT contributor emails

* fix(tools): reuse subscription features for toolset listing

* fix(api-server): reuse toolset feature snapshot

* chore: add EndeavorYen to AUTHOR_MAP

* fix(platforms/line): fix broken import of non-existent config functions

_adapter_config_interactive() imported get_env_var and set_env_var from
hermes_cli.config, but these do not exist — the actual functions are
get_env_value and save_env_value. This caused an ImportError at runtime,
breaking the entire LINE platform adapter setup.

Pain before: Any user who ran the LINE adapter setup function would get:
    ImportError: cannot import name 'get_env_var' from 'hermes_cli.config'

Fix: Import the correct functions with aliased local names:
    from hermes_cli.config import get_env_value as _get_env, save_env_value as _set_env

Also fixed an indentation bug introduced during the fix: the 'if value: _set_env()'
block was incorrectly nested inside the except clause.

PR: N32 (hermes-agent audit)

* chore(contributors): map tbsonline@protonmail.com -> jasoisjaso (#77600)

Needed for the #59077 salvage (batch compression-tip row fetch) so
release attribution resolves the contributor's commits.

* chore: add light-merlin-dark to AUTHOR_MAP

* fix(agent): jittered, interrupt-aware backoff for empty-response retries

Empty content retries previously fired back-to-back with no delay,
wasting up to 3 rapid API calls, and could not be cancelled mid-wait.
Apply the same jittered_backoff() already used for rate-limit and
API-error retries, sleeping in small increments so a user interrupt
aborts the wait instead of blocking until it elapses.

Fixes #35230

* test: fake clock for the backoff-status test (was busy-spinning 7.5s)

The retry loop gates on real time.time() < sleep_end; with sleep mocked
to a no-op the test hot-spun 7.5 wall-clock seconds. Advance a fake
clock by each sleep amount instead (pattern precedent:
test_session_activity_persist.py).

* perf(providers): cache provider list snapshots

* test: pin the hit-path copy guard on the provider snapshot cache

The existing test only mutated the miss-path return; a mutation to
'return _PROVIDER_LIST_CACHE' (aliasing the global cache) survived the
suite. One line pins the cached-return copy. Mutation-checked.

* feat(gateway): add opt-in 'latency' runtime footer field

The runtime footer (`/footer`) shows what model ran and how full the context
is, but not how long the turn took. On a messaging platform there is no
progress bar and no shell timer — a turn that took 4 seconds and one that took
four minutes produce visually identical replies. Users comparing models,
providers, or reasoning levels have no at-a-glance signal for the one
dimension they most often care about, and "was that slow or did I imagine it?"
is unanswerable after the fact.

Adds a `latency` field to the existing footer machinery, rendering the
wall-clock duration of the agent run: `<1s`, `22s`, `1m05s`.

`gateway/run.py` measures with `time.monotonic()` immediately around the
`self._run_agent(...)` await in `_handle_message_with_agent` — the same
function that already builds the footer, so the value is the user-perceived
turn duration (monotonic, so it is immune to wall-clock/NTP adjustment).

`latency` is deliberately NOT in `_DEFAULT_FIELDS`. It is opt-in via
`display.runtime_footer.fields`. Every existing footer — and every footer a
user has today without touching config — renders byte-identically.

This is enforced by tests, not just asserted:

- `test_latency_not_in_default_fields` pins the default tuple.
- `test_resolve_footer_config_default_fields_exclude_latency` pins what
  config resolution produces for an untouched config.
- `test_default_footer_renders_byte_identically` pins five exact output
  strings for default-config renders **while supplying `turn_seconds`** —
  proving that even when the caller measures timing, a default-configured
  footer does not show it.
- `test_default_build_footer_line_ignores_turn_seconds` asserts
  `build_footer_line(...) == build_footer_line(..., turn_seconds=125.0)`
  under default fields.

Adding `latency` to `_DEFAULT_FIELDS` fails 11 of these tests.

No new config surface (reuses `display.runtime_footer.fields`), no new env
vars, no new core tool, no new model-facing schema. One new module-private
helper (`_format_latency`), one new keyword argument threaded through the two
existing footer functions, and 3 lines in `gateway/run.py`.

`turn_seconds` defaults to `None` and the field is skipped when it is `None`
or negative, so any call site that does not measure timing keeps working
unchanged.

`tests/gateway/test_runtime_footer.py` (+185): `_format_latency` boundary
table (sub-second, rounding at 59.4/59.6, the `m{:02d}s` zero-pad, 60m), the
render/skip/opt-in matrix, field-order placement, `build_footer_line`
threading, and the byte-stability block above.

RED-proved by mutation — each of these breaks tests:
- `latency` added to `_DEFAULT_FIELDS` → 11 failures
- dropping the `turn_seconds is not None and >= 0` guard → 2 failures
- `{sec:02d}` → `{sec}` → 6 failures
- `build_footer_line` not threading `turn_seconds` → 1 failure

51 passed in `tests/gateway/test_runtime_footer.py`; 54 passed across the
footer blast radius. `ruff check` clean.

* perf(transport): gate prompt cache keys by provider capability

* feat(transport): imply prompt_cache_key capability for api.openai.com

Review follow-up on the #56798 salvage: the gate shipped fully dormant
(no provider profile sets supports_prompt_cache_key, no production
caller passes it, and no plain 'openai' profile exists to set it on) —
AGENTS.md rejects dead code wired in without E2E proof.

Activate the one endpoint where the field is first-class: exact-host
api.openai.com (OpenAI documents prompt_cache_key; GPT-5.6+ docs
recommend it for cache routing). Deliberately NOT substring matching —
Azure/OpenAI-compat endpoints may reject unknown fields and stay
opt-in via the flag. 4 new tests (imply + 3 spoof/proxy/Azure
negatives); mutation-checked (substring-weakened host check fails the
spoof tests).

* perf(gateway): reuse loaded turn config for timestamp check

Re-derivation of PR #65645 onto current main: _build_gateway_agent_history
already runs inside a turn whose config was loaded once into
ctx.user_config; re-reading config from disk via _load_gateway_config()
per turn is redundant. Reuse the loaded turn config.

* perf(cli): add --prefer-offline to npm install during update (#39267)

Re-derivation of PR #39399 onto current main: pass --prefer-offline to
the web-UI workspace install (both silent and verbose arms of
_install_web_deps) and to the update-time Node dependency refresh in
_update_node_dependencies, so npm reuses its local cache instead of
re-fetching metadata. Test expectations updated to match, mirroring the
PR's own test-update commit.

* perf(cron): skip config load on idle scheduler ticks (idea from #33612)

Re-derivation of #33612 by @LeonSGP43 onto the rewritten scheduler (the
original is 10,692 commits behind; its tick() no longer exists in that
shape, so this is a fresh minimal fix crediting the PR's idea).

The gateway's built-in ticker calls tick(verbose=False) every 60s. The
idle early-return was gated on 'verbose and not due_jobs', so idle
GATEWAY ticks fell through to load_config() + worker-pool resolution
every minute. Return early on ANY idle tick; keep the post-tick MCP
orphan sweep (main intentionally reaps orphaned stdio children on idle
ticks).

3 new tests; mutation-checked (restoring the verbose-gated guard fails
the config-skip test). 66 scheduler tests green.

* fix(feishu): defer the lark_oapi import off the startup path

Salvage of #57657, ported onto the plugin layout (the adapter moved
from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py
since the PR's base). lark_oapi takes seconds to import and holds the
GIL doing it; the module-level import made every gateway boot pay that
cost even with Feishu unconfigured.

- _load_lark_oapi() with double-checked locking binds the SDK globals
  on first use; connect() and _standalone_send() call it via
  asyncio.to_thread so the loop never blocks on the import.
- probe_bot() also calls _load_lark_oapi() (sync context) so the SDK
  probe path is preserved rather than silently degrading to the HTTP
  fallback before a first connect.
- check_feishu_requirements() is install-only and no longer rebinds
  globals; test_feishu.py gets a setUpModule that binds them eagerly
  for tests that inject fake clients.

Includes the dedicated lazy-import test file (check-does-not-import,
connect-loads-on-worker-thread).

* test: bind lark SDK globals session-wide, not per-file

CI exposed the whole class: feishu tests across MANY files (thread
routing, text batching, sdk executor, ...) inject a mock _client and
skip connect(), so the deferred import leaves the request-builder
globals None. Replace the single-file setUpModule with a session-scoped
autouse conftest fixture that binds the globals once when lark_oapi is
installed; when it isn't, the affected tests already skip via their own
skipUnless guards. Full tests/gateway run: zero failures beyond main's
pre-existing baseline (sorted failure-diff).

* fix(feishu): test SDK globals by None-ness, not globals() membership

The no-SDK fallback guards check '"Name" in globals()' — correct on
main where a failed module-level import leaves those names undefined,
but the deferred-import port pre-binds every SDK name to None, so the
guard was always true and the fallback paths called .builder() on None
(AttributeError) wherever lark_oapi isn't installed. Local runs passed
because lark IS installed here; CI's default env has no feishu extra.
Rewrote all 14 guards to 'is not None', which is correct under both
conditions. Verified by simulating CI with a lark-blocking meta_path
hook: 74 passed, 18 skipped (the skipUnless set), zero failures.

* chore: add contributor email mapping for WojtekMR3

* perf: replace COUNT(*) with LIMIT-based existence checks

Two places were using SELECT COUNT(*) when they only needed a boolean:
- has_any_sessions() called session_count() > 1 (full table scan)
- delete_session() used SELECT COUNT(*) WHERE id=? (full matching scan)

Fix:
- Add session_count_ge(n) to SessionDB — short-circuits via
  SELECT 1 FROM sessions LIMIT n, returns bool
- has_any_sessions() uses session_count_ge(2) instead of session_count() > 1
- delete_session() uses SELECT 1 ... LIMIT 1 with fetchone() is None
- Add tests for session_count_ge

* fix(state): take the connection lock in session_count_ge + document archived semantics

Review fold-ins on top of #56768 (@Skywind5487):
- session_count_ge ran its query without self._lock, unlike every
  sibling counter on SessionDB (session_count, session_count_by_source).
- Document the deliberate semantics change: session_count() defaults to
  archived = 0, which is both the expensive part (full index scan,
  measured 543us vs 4us on 20k sessions) and wrong for the only caller
  (has_any_sessions asks 'has this install ever had sessions' -- an
  archived session is still a created one).

* perf(state): index assistant tool-call rows for Insights queries

InsightsEngine._get_tool_usage and _get_skill_usage scan messages for
role='assistant' AND tool_calls IS NOT NULL, but no index aligns with
that predicate, so SQLite scans the full messages table on a large
state.db. Add a partial index over exactly those rows.

role and tool_calls are base columns in the messages table, so the index
lives in SCHEMA_SQL (created on both fresh and existing databases via the
executescript on every open) rather than DEFERRED_INDEX_SQL.

Adds schema regression coverage (fresh + reopened DB, plan uses the index)
and an Insights regression test proving tool/skill output is identical with
and without the index present.

Fixes #67341

* perf(insights): pin partial index on assistant tool-call queries

Review follow-up (#67341): on a freshly initialized state.db (before
ANALYZE has run) the source-filtered branches of _get_tool_usage /
_get_skill_usage did not select idx_messages_assistant_calls_by_session
— the optimizer drove from idx_sessions_source_id and probed each
session's messages via idx_messages_session_active, scanning non
tool-call rows. Pin the index with INDEXED BY on all four fixed-predicate
branches so the plan is deterministic for both the unfiltered and
source-filtered scopes without depending on statistics.

Safe because the index is declared in SCHEMA_SQL (created by every
read-write SessionDB._init_schema) and every InsightsEngine caller opens
a read-write SessionDB; read-only attachments (which skip schema init)
are never used for insights.

Extract the four queries into class constants and add tests: query-plan
coverage for both scopes without ANALYZE, row-level equivalence between
pinned and un-pinned forms, and an assertion that INDEXED BY fails loudly
if the index is absent.

* fix(insights): fall back to unpinned queries when the partial index is absent

The INDEXED BY pin is a hard dependency -- SQLite raises 'no such
index' when the named index is missing. That happens in production:
the web dashboard's usage analytics (_get_usage_analytics,
_get_models_analytics) open state.db read_only=True, which skips
_init_schema, so a DB last written by a pre-index version has no
idx_messages_assistant_calls_by_session and every insights call
crashes with OperationalError (reproduced E2E).

Probe sqlite_master once in __init__ and strip the pin from the four
prepared statements when absent -- identical rows, optimizer-chosen
plan, no crash. Replaces the change-detector test that froze the
crash as intended behavior with a fallback-equivalence test.

* refactor(insights): strip INDEXED BY pins via an attribute loop

Simplify-pass fold: the four copy-pasted .replace blocks meant a\nfifth pinned statement could forget its strip line — a hard 'no such\nindex' crash on read-only DBs, the exact bug the fallback prevents.\nLoop over the attribute names instead.

* perf(state): batch compression-tip row fetch in list_sessions_rich

list_sessions_rich()'s compression-root projection called
_get_session_rich_row() once per root — a separate single-row query per
compression root on every session-list render. Resolve every tip id
first, then fetch all tip rows in one WHERE id IN (...) query via the
new _get_session_rich_rows_batch().

_get_session_rich_row() is now a thin wrapper over the batch method, so
the enriched SELECT (preview + last_active) lives in exactly one place —
future column changes (e.g. #42196's include_system_prompt) only touch
one query.

get_compression_tip()'s chain walk is untouched; it's a genuine
per-session graph walk with branch/delegate-exclusion and race handling,
and batching it safely is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(state): guard compact_rows threading through batched tip-row fetch

Adds two regression tests for the #59077 batch: (1) _get_session_rich_rows_batch(compact_rows=True) uses the schema-derived compact projection (no system_prompt, git_branch/git_repo_root kept); (2) list_sessions_rich(compact_rows=True) threads compact_rows through the compression-tip projection call site. Mutation-checked: hardcoding compact_rows=False at the call site fails test 2.

* refactor(state): chunk the batched tip-row IN clause at 900 ids

Simplify-pass fold: SQLITE_MAX_VARIABLE_NUMBER is 999 on pre-3.32\nbuilds (which the repo still supports — the trigram-availability\nmachinery exists for exactly that class), and limit=10000\nlist_sessions_rich callers exist in web_server. Chunk inside the\nbatch helper — the single choke point — so no call site can overflow.

* fix(yuanbao): clear _processing_msg_ids/_processing_msg_texts after each message

_dispatch_inbound_event() writes session_key → msg_id/raw_text into
_processing_msg_ids and _processing_msg_texts so RecallGuardMiddleware
can find and interrupt the currently-processing message.  These entries
were never removed after a message finished processing, causing both
dicts to grow unboundedly — one persistent entry per unique session key
for the lifetime of the bot.

Fix: clear both entries in the _process_message_background() finally
block, after super() returns.  The guard compares the stored msg_id
against event.message_id before popping: a concurrent pending message
may have already overwritten the entry in _dispatch_inbound_event while
we were running, in which case the drain task owns it and we must not
clear it.  When msg_id is absent (nothing was written at dispatch time)
the pop is a safe no-op.

Note: _msg_content_cache already bounds itself to 200 entries at the
same write site; _processing_msg_ids and _processing_msg_texts had no
such bound.

* fix(yuanbao): evict stale entries from _member_cache on TTL expiry

_build_msg_body_with_mentions() checks the TTL of each _member_cache
entry and returns an empty member list when the entry is stale, but
never removes the entry from the dict.  Over time every group_code the
bot has ever queried accumulates a permanent entry, retaining the full
member list (potentially thousands of records per group) until
disconnect().

Fix: delete the stale entry at the point it is detected as expired.
The next call to get_group_member_list_raw() for the same group will
repopulate the cache with fresh data as before.

Symmetric with the existing TTL pattern in MessageDeduplicator, which
evicts on access.

* fix(yuanbao): pop tracking entries only for truthy matching msg_id + regression tests

Follow-up on the salvaged pair: the original guard's `not msg_id` arm let an
id-less internal/synthetic event erase a tracking entry a concurrently-queued
id-bearing message's drain task still needs for recall matching (id-less
events never write entries in _dispatch_inbound_event, so they must never
pop). Tests cover: normal cleanup, id-less non-erasure, overwritten-entry
ownership handoff, TTL eviction + fresh-entry survival.

* chore: add frizikk to AUTHOR_MAP

* perf(zai): parallelize endpoint detection probes

Z.AI has separate billing for general vs coding plans and global vs
China endpoints. On startup, detect_zai_endpoint() probes up to 4
endpoints sequentially with 8s timeout each, taking 8-9 seconds when
the first endpoints return non-200 (rate limited) before a working one
is found.

Replace the sequential loop with concurrent.futures.ThreadPoolExecutor
to probe all 4 endpoints in parallel. Results are returned in
ZAI_ENDPOINTS priority order so the preference chain is preserved.

Benchmark on macOS M4 Max, Python 3.11, Hermes v0.8.0:
  Before: 8.8s (sequential: global=0.9s/429, cn=1.6s/429,
           coding-global=4.3s/200, coding-cn=2.0s/200)
  After:  ~4.5s (single round-trip, bounded by slowest endpoint)

Signed-off-by: Merlin <merlin@merlin.me>

* test(zai): cover parallel-probe contracts + restore candidate-model loop

Rebase fold: the original PR predates ZAI_ENDPOINTS growing per-endpoint
probe_models lists; the parallel worker now preserves that candidate-model
fallback loop (was: scalar model). Tests (both mutation-checked):
- candidate-model fallback within one endpoint worker
- ZAI_ENDPOINTS priority order wins over completion order
- all-fail returns None

* perf(zai): early-exit when the highest-priority endpoint wins (simplify finding)

The as_completed drain + `with` join made the parallel version WORSE than
sequential main in the common case (first endpoint succeeds fast, others
slow/unreachable): main returned at first success, the parallel version
waited for every straggler. Now: after each completion, walk endpoints in
priority order and return as soon as a success is unbeatable (all
higher-priority probes already finished); pool uses shutdown(wait=False) so
losers drain in the background. Mutation-checked: removing the early exit
makes the new timing test fail (8.2s vs <1.5s).

* Bound MiniMax OAuth error responses

* fix(minimax-oauth): read streamed error bodies inside the client context + real-transport tests

Follow-ups on the salvaged bounded-read fix:
- refresh flow: the non-200 branch reads a STREAMED body, which fails
  (ReadError/StreamClosed) once the httpx.Client context has exited —
  moved inside the context. Repro + regression test use a real socket
  server (MockTransport buffers in memory and cannot catch this).
- truncation guard: >limit bodies end with ...[truncated] (mutation-checked
  against the is_stream_consumed fallback).
- test mocks now model the streamed-read surface (is_stream_consumed,
  iter_bytes, client.send) so non-200 paths exercise the real bounded read.

* chore: map xaydinoktay@gmail.com to aydnOktay

* chore(contributors): map four B2 salvage author emails (#77641)

unixwzrd.register@mac.com -> unixwzrd (#74679); dai.suzuki.829@gmail.com -> hariNEzuMI928 (#75395); lexharddrive69@gmail.com -> hdd69 (#38470); coder@trevhome.local -> trevornk (#76282). Needed for the B2 desktop-renderer salvage attributions.

* perf(session-search): project fields before enrichment

* test(session-search): guard projected enrichment

* fix: skip memory prefetch on trivial user prompts (greetings)

Salvage of PR #25350 (commits 88ffede2d + 2b848a0b2 + 3136dc63a, squashed
and ported): the run_agent.py prefetch site the PR gated has since moved
into agent/turn_context.py's build_turn_context(), so the trivial-query
gate lands there instead.

- Gate the per-turn memory_manager.prefetch_all() on a trivial-prompt
  check so greetings/acknowledgements ('hi!', 'thanks', 'ok') no longer
  block the turn on provider network round-trips or inject stale context.
- Extend honcho's _TRIVIAL_PROMPT_RE with greetings and a trailing
  punctuation class so 'hey!' / 'hello.' classify as trivial.
- Add honcho classifier tests for greeting forms.

* chore: add ayushere to AUTHOR_MAP

* refactor(memory): single shared trivial-prompt classifier + gate tests

Rebase fold on the salvaged gate:
- is_trivial_prompt/TRIVIAL_PROMPT_RE move to agent/memory_provider (the
  ABC both the core gate and providers already import) — one source of
  truth; honcho's _TRIVIAL_PROMPT_RE now aliases it, turn_context and the
  queue_prefetch_all warm path (a sibling site main grew after the PR's
  base) both use it
- tests: gate tests at the prefetch call site (mutation-checked), shared
  classifier tests incl. prefix-collision guards (k8s/yolo/note/supper),
  and honcho dialectic-machinery tests re-driven with a substantive prompt
  ("hello" became trivial by design — those tests exercise thread cadence,
  not the classifier)

* refactor(honcho): delegate _is_trivial_prompt wholly to the shared classifier

Simplify-pass finding: sharing only the REGEX left the wrapper logic
(empty/strip/slash checks) duplicated, half-defeating the no-drift goal.
The classmethod now calls agent/memory_provider.is_trivial_prompt directly;
_TRIVIAL_PROMPT_RE stays as a class attr for backward compatibility with
any external referents.

* fix(desktop): measure adaptive stream flush through the deferred commit frame

scheduleDeltaFlush's adaptive floor is driven by lastFlushCostRef, but
runFlush only timed flushQueuedDeltas(), the synchronous store write.
While a session streams, syncSessionStateToView defers the $messages
publish (React commit + Streamdown re-parse) to its own rAF, so the
measured cost stayed near zero and the floor collapsed to the fixed
33ms path no matter how expensive the real commit was.

runFlush now records the write cost as a fallback, then extends the
measurement through a rAF registered after the view-sync one: it runs
in the same frame right after the deferred commit, and the rAF
timestamp marks frame start so only in-frame work is counted, not the
vsync wait. A stale callback from before a newer flush is ignored, and
a hidden renderer that never fires rAF keeps the write-cost fallback.

* fix(desktop): dedupe optimistic user turns for all wire references, not only images

* test(desktop): cover wire reference normalization edges

* fix(desktop): sort reference-kinds import per lint gate

* fix(desktop): full-jitter backoff on gateway WS reconnect loops

All three desktop reconnect loops (primary gateway boot, secondary
multi-profile gateway pool, plugin event socket) used bare exponential
backoff with no jitter. After a gateway restart every disconnected
client redials on the exact same schedule, so the reconnect attempts
land in lockstep instead of spreading out -- a burst that can starve
the gateway's file descriptors while it's still coming back up.

Add reconnect-backoff.ts implementing AWS-style full-jitter backoff
(random delay in [0, min(cap, base * 2^attempt))) and wire it into all
three call sites in place of their local Math.min/2**attempt math.
Manual reconnect paths already reset the attempt counter and bypass
the timer entirely -- unchanged.

* fix(desktop): escalate gateway reconnect on elapsed time, not attempt count

With the full-jitter backoff (300ms base) six attempts can elapse in ~9s,
so the old RECONNECT_ESCALATE_AFTER=6 attempt threshold raised the
recoverable boot error during a brief post-boot blip — breaking the
'a remote that drops post-boot keeps looping with NO boot.error' contract.
Escalate after RECONNECT_ESCALATE_AFTER_MS (45s, matching the old
deterministic 1->15s ladder's calibration) elapsed since the first failed
reconnect of the episode. Reset on clean open, manual/wake reconnect, and
soft switch, preserving the reset-on-success path.

* chore(contributors): map vittoria3103.123@gmail.com -> VittoriaLanzo (#77665)

Needed for the #62082 curator toolset-pin salvage attribution.

* fix(desktop): un-break the .btn-arc rule — '*/' inside a CSS comment ended it early

The comment above .btn-arc contained 'bg-*/', whose */ terminated the comment mid-sentence, leaving 'text-* variant utilities. */ .btn-arc {' as an invalid prelude — CSS error recovery can drop the whole .btn-arc rule. Reword so no */ appears inside the comment.

Extracted from #59352 by @rerdi92 (the rest of that PR — a month-stale icons.ts rewrite and a chunk-size warning-ceiling bump — is superseded/masking and was not salvaged).

* perf(desktop): stop idle chat re-renders — memo ChatView, stable tile props, gated adapter re-sync

Re-derive of PR #38470 on today's main (its target file desktop-controller.tsx no longer exists after the contrib/ refactor; the three surviving ideas are applied at their new homes):

- incremental-external-store-runtime: the dep-less setAdapter effect ran every render; gate on [runtime, store] — behavior-preserving because __internal_setAdapter early-exits on identical store.
- ChatView is now memo()d, and session-tile hoists its inline arrow props to useCallbacks/module constants so the memo actually holds.
- Render-count regression test (mocked Thread) proves an unrelated parent re-render no longer re-renders the chat shell.

Credit: idea and original implementation by @hdd69 in #38470.

* perf(curator): trim dead tool-schema from the LLM review fork

The curator LLM review loop (_run_llm_review) built its AIAgent without
enabled_toolsets, so it advertised the full default catalog (~30 tools plus the
context_engine lcm_* family) on every call. The fork uses only four tools, fixed
by its own system prompt, with no dispatch path to the rest, so ~26 tool schemas
shipped on every request as dead weight: ~7K input tokens per call on a loop that
makes 50-100 calls per consolidation pass.

Restrict the fork to enabled_toolsets=["skills", "terminal"], the same tools the
prompt already names. Behavior-neutral: the prompt held the model to these tools
and nothing routed calls to the others. Mirrors the background_review fork
(background_review.py:788-794). Call-site only; AIAgent already forwards the kwarg.

Adds test_review_fork_restricts_toolsets_to_skills_and_terminal (captures the
constructor kwarg) and test_review_fork_toolset_surface_is_skills_plus_terminal
(pins the resolved surface).

* fix(desktop): keep a mid-turn reply on screen when its session is reopened

Switching sessions while a turn streams (or right as it completes) could
leave the assistant reply missing until restart. Resume merges stored
history with the gateway's `inflight` projection, whose assistant row is
text-only and often an empty `assistant-stream-${sessionId}` shell; both
reconcile paths then dropped the local pending row that held the only copy
of the streamed text, reasoning and tool calls.

A shared pair of guards replaces the ad-hoc comparisons at all three sites.
`localPendingSupersedes` accepts the cached row only when it is the same
reply further along — an empty shell it has content for, or text it strictly
extends — so a longer unrelated row can no longer hijack an ordinal or reuse
a stream id, and a retained `inflight.error` snapshot is never mistaken for
an empty shell. `withAuthoritativeTurnState` then takes content from the
renderer while liveness, row id and reactions stay the backend's call, so a
settled shell cannot leave a finished reply spinning.

Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>

* fix(desktop): stop a finished reply rendering twice after history catches up

When a turn's reply commits under its own id, the settled local
`assistant-stream-*` row shifts one assistant ordinal earlier, so ordinal
pairing finds nothing at its slot and re-appends it — the same answer twice.

Drop a settled stream row only when the authoritative transcript already
carries that exact text. Keying `isPendingAssistant` on the explicit pending
flag alone would also have fixed this, but it discards the sibling case in
the same report: a reply that finished locally before the gateway committed
it, where the local row is the only copy that exists.

Co-authored-by: Dolverin <59100064+Dolverin@users.noreply.github.com>

* chore(contributors): map two B3 salvage author emails (#77685)

abdulsalamalotaibi86@gmail.com -> carbongotfound (#74025); soundbrokaz@kakao.com -> JeremyDev87 (#72813).

* refactor(desktop): hoist the reference-line matcher; drop dead textWithoutImageRefs

Follow-up to #77653: textWithoutReferenceLines built a fresh /g RegExp per call and hand-managed lastIndex — but it runs on both sides of every message comparison in the reconcile loops. An anchored non-global regex has no shared-lastIndex hazard and can be hoisted to module scope. Also removes textWithoutImageRefs, whose last production consumer #77653 replaced (kept IMAGE_REF_LINE_RE for extractImageRefs), and retargets its now-stale comment.

* fix(desktop): do not sandwich structured mid-turn rows with inflight dump

Skip pure-text inflight.assistant projections when the transcript already
has reasoning/tool-call structure, and only overlay journal answer text
on strict extension.

Fixes #76444

* fix(desktop): scope inflight dump suppression to the live turn tail

Only skip/graft structure for the current live assistant (stream id,
pending, or after the latest user), not completed historical tool rows.
Require live-tail identity for same-turn structure carry. Align journal
overlay with strict answer-text extension.

Addresses review + CI on #76744.

* fix(desktop): require structure-bearing row for live-tail same-turn carry

Structure-only same-turn carry used (live(previous) || live(message)), so a
new live text-only assistant at a compression-rewritten ordinal could inherit
reasoning/tool parts from an unrelated historical structured row.

Require the structure-bearing cached row itself to be live-tail (pending /
assistant-stream-* / interim). Add regressions for non-extending live dump
carry and the compression graft rejection.

Addresses salvage path on #76744 / #76444.

* refactor(desktop): one live-tail vocabulary for transcript reconciliation

Two fixes landed overlapping helpers on the same statement: the mid-turn
reply guard grew `isLiveProjectionRow` / `hasStreamedContent`, while the
inflight-dump guard grew `isLiveTailRow` / `hasStructuralParts`. Two
definitions of "is this row live" and "does it carry content" in one
function is how the next change silently reshapes one of them.

Collapse to a single module-level pair. `isLiveTailRow` now covers pending,
stream ids, inflight projections and sealed interim rows, so the reply guard
also stops treating an interim row as committed history; `hasStreamedContent`
is defined in terms of `hasStructuralParts`. Both text-extension checks route
through `isStrictAnswerTextExtension` rather than a bare `startsWith`.

Also hoists the live-tail lookup out of an inline IIFE and fixes the lint
warnings it carried.

Co-authored-by: 686f6c61 <github@00b.tech>

* fix(dashboard): cache plugins hub payload and avoid auth probes

* test(dashboard): cover install-hook invalidation of plugins hub cache

* fix(dashboard): warm cold check_fn verdicts with a background probe

On dashboard-only sessions nothing else executes check_fn warmers (they
live only in the tool-schema build), so the hub's read-only cache lookup
would report auth_required=False forever. On a cache miss, schedule a
deduplicated daemon-thread probe off the request path; the short hub TTL
surfaces the verdict on the next fetch.

* fix(desktop): cancel the pending commit-cost measurement rAF

Follow-up to #77652: each runFlush registered a fresh requestAnimationFrame and never cancelled it. Chromium parks rAF callbacks for hidden renderers, so a long hidden stream at the 33ms floor accumulates thousands of parked closures that all fire in the first frame on refocus (all but one no-oping through the stale-frame guard). Track the pending handle, cancel it before requesting a new one (only the newest flush's measurement matters), and cancel on unmount.

* perf(dashboard): skip full InsightsEngine on /api/analytics/usage (#18511)

* perf(dashboard): keep tools in focused analytics usage (#18511)

* refactor(insights): drop consumer-less get_skill_breakdown alias (simplify-pass)

The 2-line alias had zero production consumers (web_server calls
get_usage_breakdown directly). Tests rewired onto the real API; the
contracts they pin are unchanged. Stale test docstring fixed.

* fix(web): clamp dashboard pagination and analytics-days params (#39200 + #74778 salvage)

Re-derivation of aydnOktay's twin clamp PRs onto current main (the
session-list endpoints moved into web_routers/; the analytics endpoints
gained asyncio.to_thread wrappers since the originals):

- limit le=100 on /api/sessions, /api/sessions/search and the
  /api/profiles/sessions fan-out (one unbounded request could drag every
  session row + correlated-subquery preview work out of SQLite, times
  every profile's state.db on the fan-out).
- days ge=1 le=365 on /api/analytics/usage + /api/analytics/models
  (huge or non-positive values force full-history InsightsEngine work or
  inverted windows; the UI only offers 7/30/90 presets).

FastAPI Query bounds reject at the validation layer (422). 8 new tests;
both clamp classes mutation-checked (clamp removed -> its tests fail).

* fix(clamps): raise profile fan-out limit to le=500 (simplify-pass finding)

le=100 would 422 real desktop callers: sessions-settings fetches
archived at limit=200, the command palette lists at 200, and the
electron remote-merge over-fetches limit+offset (exceeds 100 at
offset>=81, and its .catch(()=>null) silently drops remote sessions).
Clamp must sit above real client maxima. New test pins limit=200 w/
offset.

* fix(web): avoid blocking provider validation

* perf(plugins): seed plugin routes from sessionStorage cache for instant render

- Plugin manifests are now cached in sessionStorage on fetch.
- On refresh, plugin routes are registered synchronously from cache, preventing unwanted redirects to /sessions.
- Removes the !pluginsLoading guard from the catch-all route in App.tsx, as plugin routes are now always available on first render.
- Background fetch always updates the cache and routes, so new/removed plugins are reflected after reload.
- Resolves the race condition where plugin pages would redirect to /sessions on hard refresh.

* fix(plugins): validate cached manifests are an array

* test(plugins): export cache helpers and add focused fallback/refresh tests

* fix(plugins): keep loading gate when cached manifests include a /chat override

The sessionStorage seed set loading=false whenever any cache existed, which
defeats App.tsx's load-bearing pluginsLoading gate: with a cached manifest
that declares tab.override === "/chat", the persistent ChatPage host must
NOT mount before plugins resolve, or it spawns a PTY and gets yanked when
the override plugin takes over the route.

Seed loading=false from the cache only when no cached manifest overrides
/chat (canSeedLoadedFromCache); manifests are still seeded either way so
plugin routes register synchronously on refresh. Adds focused tests for
the gate, including the /chat-override case.

* perf(dashboard): serve hashed /assets bundles with immutable cache headers

Every hashed bundle chunk under /assets/ was served with no caching
directives, so each dashboard load re-fetched (or at best revalidated)
every JS/CSS chunk. Those filenames carry a Vite content hash — the
bytes behind a given URL can never change; a rebuild mints new
filenames referenced by a freshly served index.html.

Mark them Cache-Control: public, max-age=31536000, immutable:
- the /assets StaticFiles mount, via a subclass that stamps the header
  on 200s only (404s stay uncached — a rebuild can create the file),
- serve_css, preserving its X-Forwarded-Prefix url() rewrites for
  /fonts/, /fonts-terminal/, /ds-assets/, /assets/.

index.html keeps no-store, no-cache, must-revalidate — it is the
mutable entry point that binds users to the current hashes.

The original PR also added hand-rolled per-request gzip compression of
asset responses; that part is deliberately dropped. This server is a
localhost-default dashboard backend: compressing every response on the
CPU to save loopback bandwidth is a pessimization, and callers that
front it with a real proxy already get compression there.

Salvaged from PR #28543 (idea by @sea-monsters; gzip groups dropped as
described above).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: reset-aware primary restore — stay on fallback until the rate-limit window resets

restore_primary_runtime retries the primary every turn once the 60s
transient cooldown clears. For subscription-window limits (Claude
Pro/Max 5h windows, Codex weekly caps) the reset is hours or days away,
so every retry is a guaranteed failure costing two provider switches
and two prompt-cache invalidations per turn.

Add CredentialPool.next_available_at() (earliest reset across exhausted
entries; None when available now or no reset info) and gate the restore
on it: skip while the primary's pool says nobody can serve, restore on
the first turn after the reset elapses. Fail-open: any gate error or
missing reset info falls through to the existing per-turn retry, so
recovery can never be later than today. Cross-provider fallbacks
consult the PRIMARY's pool (not the attached fallback pool), reusing
the loaded pool for the existing rebind to keep auth reads at one per
restore.

* fix(credential_pool): run next_available_at under the pool lock

Review fold on the #67642 salvage: next_available_at() called
_available_entries() — which prunes DEAD entries, syncs tokens, and
persists — and iterated self._entries with no lock, racing concurrent
select()/rotation exactly as has_available()'s comment warns. Wrap the
method body in self._lock and pin it with a non-blocking-acquire probe
test.

* fix(credential_pool): defer single-use-token refresh outside threading lock

select() and acquire_lease() held self._lock during the entire
_available_entries() loop, which for openai-codex and xai-oauth providers
includes a cross-process file lock (_auth_store_lock) plus OAuth token
refresh HTTP POST.  The lock timeout can exceed 20 seconds, blocking all
credential pool consumers across every gateway thread and subagent.

Collect single-use-token refresh entries under the lock, then execute the
refreshes outside it.  On success the refreshed entry is merged back into
the pool and re-selected.  Non-single-use providers (anthropic, nous)
continue refreshing inside the lock since their refresh is a simple HTTP
POST with no cross-process coordination.

* fix(credential_pool): serialize deferred-refresh pool mutations

Review folds on the #71775 salvage (dossier findings 1+2):

- self._lock becomes an RLock and the mutation primitives
  (_replace_entry, _persist) are now self-locking, so the deferred
  single-use-token refresh path — which deliberately runs its
  cross-process flock + OAuth network I/O OUTSIDE the pool lock —
  still serializes its pool mutations against concurrent
  select()/rotation. In-lock callers re-acquire reentrantly.
- Dropped _refresh_pending_entries' redundant second _replace_entry:
  _refresh_entry already merges the refreshed entry internally.

Adds tests/agent/test_credential_pool_deferred_refresh.py pinning both
invariants: select() must NOT hold the lock during the refresh window
(the PR's whole point), and the post-refresh mutations MUST contend on
the lock (blocking-thread probe).

* `hermes sessions optimize-storage` aborts with

```
Error: optimization failed: no such table: messages_fts_trigram
No data was lost. Re-run to resume.
```

on any install where the trigram FTS index is legitimately absent. The failure is
deterministic — re-running can never make progress, because the crash happens at the same
point every time — so the database is permanently stuck on the legacy high-footprint FTS
layout with no supported way forward.

Observed on a 5.4 GB production `state.db`. After the fix the same database optimized
successfully and shrank to 3.3 GB.

The trigram index is absent whenever the runtime cannot maintain it. On a SQLite build
without the `trigram` tokenizer, `_ensure_fts_schema()` returns `False`, so `__init__`
leaves `self._trigram_available = False` and no `messages_fts_trigram` table on disk. This
is a **supported degraded runtime**, not damage — CJK/substring search falls back to
`LIKE` and everything else works normally. `_is_fts5_unavailable_error()` and
`_warn_trigram_unavailable()` exist specifically to make this path graceful.

Two code paths write the boundary sweep for the deferred FTS rebuild, and only one of them
respects that flag:

| Function | Trigram `INSERT` guarded? |
|---|---|
| `fts_rebuild_step()` | ✅ `if include_trigram:` where `include_trigram = self._trigram_available` |
| `_fts_rebuild_finish()` | ❌ unconditional |

`_fts_rebuild_finish()` runs the boundary sweep at the *end* of the backfill. Its
unguarded `INSERT INTO messages_fts_trigram …` raises `OperationalError`, which propagates
out of `optimize_fts_storage()` and aborts the entire optimization — *after* the backfill
has already completed. Hence the characteristic output showing 100% progress immediately
before the error:

```
Rebuilding index: 100% (909,671/909,671)
Error: optimization failed: no such table: messages_fts_trigram
```

There is a second, quieter consequence. The teardown phase that reclaims the demoted
`fts_v22_trash_*` shadow tables runs *after* the backfill phase in
`optimize_fts_storage()`. Because the crash happens before teardown is ever reached, those
tables are never emptied or dropped — so the space the migration was supposed to reclaim
stays allocated indefinitely, and the leftover trash tables look (misleadingly) like
evidence of a half-finished migration.

Build a populated v23 database, set the deferred-rebuild markers, then reopen it on a
runtime where `_ensure_fts_schema('messages_fts_trigram', …)` returns `False` (exactly
what a SQLite build without the trigram tokenizer produces) and call
`optimize_fts_storage()`:

```
[precondition] trigram absent, _trigram_available=False, rebuild pending  ✓

RED  ✗ optimize_fts_storage raised OperationalError: no such table: messages_fts_trigram
```

With this patch applied, unchanged harness:

```
     optimize_fts_storage returned {'ok': True, 'vacuumed': None}
GREEN ✓ optimize ok; markers cleared; base FTS 'zebra' -> 200 hits
```

Full harness and transcripts in `TEST-EVIDENCE.md`.

Gate the sweep on `self._trigram_available`, exactly as `fts_rebuild_step()` already does:

```python
include_trigram = self._trigram_available

def _do(conn):
    ...
    if include_trigram:
        conn.execute("INSERT INTO messages_fts_trigram(...) ...")
```

The base `messages_fts` sweep and the marker cleanup are untouched, so the rebuild still
finalizes correctly and the index remains complete for every row it is responsible for.
The fix does not disable or weaken search to dodge the error — the regression tests assert
that base FTS still returns results afterwards.

`TestFtsRebuildFinishWithoutTrigram` in `tests/test_hermes_state.py`:

- `test_rebuild_finish_skips_trigram_when_unavailable` — drives `_fts_rebuild_finish()`
  directly on a trigram-less runtime; asserts it completes, clears both rebuild markers,
  and leaves base FTS searchable.
- `test_optimize_fts_storage_succeeds_without_trigram` — end-to-end through the public
  `optimize_fts_storage()` entry point; asserts `ok=True`, markers cleared, search intact.

Both use the existing `_NoTrigramConnection` helper already in the file. Both fail on
`main` with `no such table: messages_fts_trigram` and pass with this patch.

`tests/test_hermes_state.py` passes in full (463 tests → 465 with these two). `ruff` clean.

This PR is the crash only.

A companion PR narrows `_db_opens_cleanly()` so that
`hermes sessions repair --check-only` stops reporting a write-broken FTS schema as
healthy — the gap that makes this class of problem hard to diagnose in the first place.
The two are independent and can land in either order.

* perf(tools): shrink lazy tool catalog overhead

* refactor(tool-search): drop dead fallback ladder in _available_source_summary

Simplify-pass finding: _listing_group_label already falls back to 'other' for empty source names, and _classify_source guarantees source_name=='' only when source=='other' — both  legs were dead by construction. Aligns the summary path's grouping with the listing path.

* fix(credential_pool): unpack the tuple in next_available_at's gate

Cross-PR interaction fix: #77714 (salvage of #71775) changed
_available_entries to return (available, pending_refresh) while #77631
(salvage of #67642) added next_available_at() which still truthiness-
tests the bare return. A non-empty tuple is always truthy — even
([], []) — so the reset-aware gate silently returned None ('no wait
info') for every exhausted pool, disabling the feature #77631 shipped.
Unpack the tuple and test the available list.

Also adapts the lock-probe test for the RLock introduced by #77714
(same-thread non-blocking acquire always succeeds on an RLock; probe
from a helper thread instead).

* chore: map copii.list@gmail.com to stremtec

* chore(contributors): map marzukia@users.noreply.github.com -> marzukia (#77774)

Needed for the #37117 salvage (#77696 CI failure).

* chore: map bot@bkstock.dev to BKStock

* perf(session): route SQLite PRAGMAs through central apply_database_pragmas

Addresses review from @teknium1 on PR #71755:

- Extended apply_database_pragmas() to handle cache_size, mmap_size,
  and temp_store from config.yaml (alongside existing wal_autocheckpoint
  and journal_size_limit). No hardcoded defaults — all values are
  opt-in via config.yaml, avoiding policy conflicts with other PRs.
- Applied to ALL connection types: writer (_connect_and_init),
  read_only cross-profile attach, and WAL per-thread readers
  (_get_read_conn). Previously PRAGMAs only ran on the writer path.
- Removed inline PRAGMAs from _connect_and_init — single source of
  truth in apply_database_pragmas().
- Documented config keys with examples in function docstring.

* fix(pr): remove remnant local PRAGMAs from PR branch

* test(session): guard config-gated performance PRAGMAs across all connection types

E2E guard for the salvaged PR #71755: database.cache_size/mmap_size/
temp_store from config.yaml must reach the writer connection, the
read-only cross-profile attach, and the WAL per-thread reader — and a
default install (no database: keys) must keep byte-identical SQLite
defaults on every connection type. Also covers integer-coercion
rejection of garbage values for the three new keys.

cache_size uses -16000 (not the doc example -2000) because -2000 is
SQLite's compiled-in default and would not discriminate a regression.

* fix(desktop): flush queued deltas on window focus

* perf(desktop): stop scroll and status loops in busy sessions

* perf(desktop): pause hidden-pane timers in agents view, cron sidebar, and floating pet

Partial pick of the surviving renderer hunks from #75395 (perf commit
6502e441d plus fixup 3fbbc9c1d): gate the 500ms subagent now-ticker and
the cron sidebar 1s ticker/run-poll on usePaneVisible, and skip the
legacy floating-pet poll while the document is hidden. Dropped hunks
(electron/main.ts, vitest.setup.ts/config) intentionally excluded.

* style(desktop): restore alphabetical import order in agents/index.tsx

* refactor(desktop): shared pulse beat + fully-gated cron peek (simplify folds)

Two findings from the simplify pass on the final trio diff:

- status-pulse: one pause controller + one aligned period timer shared by all StatusPulse instances (ref-counted), instead of N x (document/window/bridge listeners + unsynchronized 5s wakes) — a sidebar can show dozens of pulsing dots. Pause still cancels in-flight animations so the compositor sleeps immediately.
- cron-jobs-section: the runs-peek effect created its interval even while the pane was hidden (callback no-oped but the timer still woke the renderer every 8s/60s per expanded job). Early-return when hidden — visibility is already in the dep array, so becoming visible restarts load + timer.

* fix(lint): import sort + eslint-disable for timer-handle ref clear in effect

CI-caught: cron-jobs-section had an extra blank line between sorted imports; use-message-stream's visibility-flush effect assigns flushHandleRef.current=null inside a useEffect (legitimate timer-clear, not an atom mirror) — eslint-disable-next-line per the rule's documented convention.

* fix(state): narrow FTS UPDATE triggers with AFTER UPDATE OF + migration

Retarget #73639 onto the SessionDB mixin split (hermes_state_common /
hermes_state_schema). Fresh installs create UPDATE OF content/tool_*
triggers; existing broad AFTER UPDATE triggers are inspected and
replaced under schema init without an FTS rebuild (WHEN clauses already
guarded content correctness; OF skips non-content status writes that
saturated disk I/O on large state.db).

Tests: tests/test_fts_update_of_narrowing.py (4)

* fix(state): fail closed on CJK trigger migration

* fix(state): quarantine CJK when ensure soft-fails after OF migration

_ensure_fts_cjk_schema never raises on OperationalError; post-condition
after dropping messages_fts_cjk_update now requires a narrowed UPDATE
trigger or durable fts_cjk_stale + unavailable. Covers the production
soft-fail path the raise-only handler missed.

* refactor(state): drop unreachable regex guard in trigger migration

Simplify-pass fold: to_drop names come from the literal update_names\nallowlist via IN binding, so the [A-Za-z0-9_]+ fullmatch could never\nfail — and if it somehow did, its `continue` would miscount (the\nskipped trigger stayed in len(to_drop)/the log while CREATE TRIGGER\nIF NOT EXISTS silently kept the broad variant). Delete the guard and\nits function-local re import; keep the invariant as a comment.

* fix(security): reject always-blocked OpenViking endpoints

## Summary
- Normalize OpenViking endpoints through `is_always_blocked_url` and fall back to the default local endpoint when poisoned.
- Keep intentional loopback / LAN self-host working.
- Add focused unit tests.

## Salvage / credit
Memory-provider endpoint floor sibling of RetainDB/Supermemory always-blocked hardening (avoids over-broad #4984-style private-IP bans).

(cherry picked from commit 8fa607d0aedb8c5fca398d7f112b1b25ade54fa2)

* fix(openviking): fail closed on blocked endpoints

(cherry picked from commit 389a90b81c9c2c89810f2fa7461f8faa9a5c9578)

* fix(openviking): don't spawn a second server onto a live port

`_start_local_openviking_server()` spawned `openviking-server`
unconditionally. Both callers — `initialize()` and the runtime
unreachable handler — reach it from a health probe, and that probe can
time out client-side while the server is up and serving. The spawned
process then loses the data-directory lock and exits immediately with
`DataDirectoryLocked`; because the probe keeps timing out, the cycle
repeats every cooldown window (~5 min observed).

The existing 30s `_failed_refresh` cooldown paces the loop but cannot
stop it, since it expires while the underlying condition persists.

Probe the target host:port before spawning and treat an occupied port as
already-started. This guards both call sites at their single convergence
point. The probe deliberately tests only that a listener owns the port —
enough to know a second server would lose the lock — and says nothing
about that listener's health.

The parse/probe now precedes the PATH lookup, so a reachable server is
reported as running even when `openviking-server` is not on PATH.

Fixes #74846

(cherry picked from commit b49427d85fd6628eb4a7fe099e5c390c5c4cc935)

* fix(openviking): drop stale "disabled for this Hermes run" warnings

The provider used to disable OpenViking permanently when the server was
unreachable. That was fixed: `_ensure_client()` now reconnects lazily,
with a 30s cooldown gate in `_ensure_client_locked`.

Only one of the seven user-facing warnings was updated to match. The
other six still told the user memory was "disabled for this Hermes run",
which is no longer true — every one of those paths is retried on the next
access. A user who reads the old message has no reason to retry, which is
very likely how #5721 ("never recovers") came to be filed against
behaviour that already recovers.

All six sites were traced to confirm none is terminal for the run: the
`initialize()`-time and waiter-thread failures never arm `_failed_refresh`
(only line 2439 does), so they retry on the very next access with no
cooldown at all.

The replacement wording deliberately omits the "(after cooldown)"
parenthetical used at the already-correct site — that detail is only
accurate where `_failed_refresh` was just armed. The neutral phrasing is
true at all six.

Also promotes two clause separators to periods to avoid "…; …disabled;"
collisions.

(cherry picked from commit 8346403a4b97af503d26b0f7905ff513828d821e)

* fix(openviking): re-arm the commit guard after in-place compression

`_committed_session_ids` is a permanent per-sid latch, and
`_session_needs_commit` checks it before the turn counter by design — a
racing sync_turn can re-increment `_turn_count` after commit+reset, so
the guard must win to stop a double-commit.

That is correct for a session being left behind. It is wrong for one
that keeps its id. `compress_context()` commits before rewriting the
transcript in both modes, and with `compression.in_place: true` (the
default) `on_session_switch` receives the same id and does not rotate.
The latch then rejects every later commit for a still-live session — the
next compression, /new, normal session end, startup recovery — so every
post-compression turn is silently never extracted.

Rotation mode is unaffected because a fresh child id is minted and
starts clean, which is what confirms the latch's intent was only ever to
dedupe the departing id.

Clear the latch when compression completes without rotation. Turns
arriving after that point are genuinely new, and this is a defined
moment rather than a race. The rotation path is untouched, so the old
id stays latched and its _finalize_session_async still dedupes against
the compression commit.

Fixes #74695

(cherry picked from commit d1e5c3dc33ef0d43d021662674e1a7cd5e43eecd)

* test(openviking): cover the compression lifecycle, not a hand-set latch

Review feedback: the previous test called _mark_session_committed
directly, so it verified the guard's behavior but not the wiring that
sets it — a future break in the commit_memory_session -> same-id
compression-boundary path would not be caught.

Add a lifecycle regression that drives the real sequence: on_session_end
commits through the actual path, on_session_switch(same id,
reason="compression") crosses the boundary, sync_turn records a genuinely
new turn, and a second on_session_end must produce a second commit POST.

Without the fix it fails showing exactly one commit call, which is the
reported data loss: every turn after the first compression is dropped.
The rotation and /undo tests stay as scope guards.

(cherry picked from commit 0ca5a330630a30b105cbbc32e8a23f2c5ffe0eab)

* fix(memory): read non-secret provider config from config.yaml for OpenViking and RetainDB

OpenViking is_available() only consulted env vars and use_ovcli_config, so an
endpoint saved to config.yaml (e.g. by the Dashboard) reported needs_config;
_resolve_connection_settings() likewise never folded config.yaml's non-secret
fields into its chain. RetainDB initialize() read base_url/project from the
environment only, ignoring the values the Dashboard writes to config.yaml.

Both now resolve non-secret fields as env -> (ovcli ->) config.yaml -> default;
secrets still come from the environment. Adds regression tests for both.

Fixes #68209

(cherry picked from commit dca57915b97b5705b30927a062e1d0f2f23d3841)

* fix(openviking): read recall settings from config.yaml first, env vars as fallback

_recall_config() previously read all settings (recall_limit, score_threshold,
recall_resources, etc.) exclusively from environment variables. This forced
users to store behavioural configuration in .env, violating the Hermes
convention that .env is for secrets only.

The infrastructure to load config.yaml -> memory.openviking was already in
place via _load_hermes_openviking_config(), but _recall_config() never
called it.

Fix: call _load_hermes_openviking_config() and pass its values as the
default parameter to _env_int/_env_float/_env_bool. Env vars still override
config.yaml values, preserving backward compatibility.

Closes #62540

(cherry picked from commit 6aadf1256835745e0302aa3d3b5ae0660b368637)

* test(openviking): cover config.yaml recall settings with temp-HERMES_HOME tests

Add three tests to TestOpenVikingConfigSchema:

1. test_recall_config_reads_from_config_yaml — writes memory.openviking
   settings in config.yaml and verifies _recall_config() consumes them.

2. test_recall_config_env_overrides_config_yaml — writes both config.yaml
   and OPENVIKING_RECALL_* env vars, verifies env takes precedence.

3. test_recall_config_partial_config_yaml — partially populated config.yaml
   falls back to defaults for omitted keys.

All 46 openviking_plugin tests pass (43 existing + 3 new).

(cherry picked from commit b8d7834caf06c6912004333c270fa…
vashkartik added a commit to vashkartik/hermes-agent that referenced this pull request Aug 4, 2026
* chore: add contributor email mapping for szzhoujiarui

* chore: map rodboev and MaartenDMT contributor emails

* fix(tools): reuse subscription features for toolset listing

* fix(api-server): reuse toolset feature snapshot

* chore: add EndeavorYen to AUTHOR_MAP

* fix(platforms/line): fix broken import of non-existent config functions

_adapter_config_interactive() imported get_env_var and set_env_var from
hermes_cli.config, but these do not exist — the actual functions are
get_env_value and save_env_value. This caused an ImportError at runtime,
breaking the entire LINE platform adapter setup.

Pain before: Any user who ran the LINE adapter setup function would get:
    ImportError: cannot import name 'get_env_var' from 'hermes_cli.config'

Fix: Import the correct functions with aliased local names:
    from hermes_cli.config import get_env_value as _get_env, save_env_value as _set_env

Also fixed an indentation bug introduced during the fix: the 'if value: _set_env()'
block was incorrectly nested inside the except clause.

PR: N32 (hermes-agent audit)

* chore(contributors): map tbsonline@protonmail.com -> jasoisjaso (#77600)

Needed for the #59077 salvage (batch compression-tip row fetch) so
release attribution resolves the contributor's commits.

* chore: add light-merlin-dark to AUTHOR_MAP

* fix(agent): jittered, interrupt-aware backoff for empty-response retries

Empty content retries previously fired back-to-back with no delay,
wasting up to 3 rapid API calls, and could not be cancelled mid-wait.
Apply the same jittered_backoff() already used for rate-limit and
API-error retries, sleeping in small increments so a user interrupt
aborts the wait instead of blocking until it elapses.

Fixes #35230

* test: fake clock for the backoff-status test (was busy-spinning 7.5s)

The retry loop gates on real time.time() < sleep_end; with sleep mocked
to a no-op the test hot-spun 7.5 wall-clock seconds. Advance a fake
clock by each sleep amount instead (pattern precedent:
test_session_activity_persist.py).

* perf(providers): cache provider list snapshots

* test: pin the hit-path copy guard on the provider snapshot cache

The existing test only mutated the miss-path return; a mutation to
'return _PROVIDER_LIST_CACHE' (aliasing the global cache) survived the
suite. One line pins the cached-return copy. Mutation-checked.

* feat(gateway): add opt-in 'latency' runtime footer field

The runtime footer (`/footer`) shows what model ran and how full the context
is, but not how long the turn took. On a messaging platform there is no
progress bar and no shell timer — a turn that took 4 seconds and one that took
four minutes produce visually identical replies. Users comparing models,
providers, or reasoning levels have no at-a-glance signal for the one
dimension they most often care about, and "was that slow or did I imagine it?"
is unanswerable after the fact.

Adds a `latency` field to the existing footer machinery, rendering the
wall-clock duration of the agent run: `<1s`, `22s`, `1m05s`.

`gateway/run.py` measures with `time.monotonic()` immediately around the
`self._run_agent(...)` await in `_handle_message_with_agent` — the same
function that already builds the footer, so the value is the user-perceived
turn duration (monotonic, so it is immune to wall-clock/NTP adjustment).

`latency` is deliberately NOT in `_DEFAULT_FIELDS`. It is opt-in via
`display.runtime_footer.fields`. Every existing footer — and every footer a
user has today without touching config — renders byte-identically.

This is enforced by tests, not just asserted:

- `test_latency_not_in_default_fields` pins the default tuple.
- `test_resolve_footer_config_default_fields_exclude_latency` pins what
  config resolution produces for an untouched config.
- `test_default_footer_renders_byte_identically` pins five exact output
  strings for default-config renders **while supplying `turn_seconds`** —
  proving that even when the caller measures timing, a default-configured
  footer does not show it.
- `test_default_build_footer_line_ignores_turn_seconds` asserts
  `build_footer_line(...) == build_footer_line(..., turn_seconds=125.0)`
  under default fields.

Adding `latency` to `_DEFAULT_FIELDS` fails 11 of these tests.

No new config surface (reuses `display.runtime_footer.fields`), no new env
vars, no new core tool, no new model-facing schema. One new module-private
helper (`_format_latency`), one new keyword argument threaded through the two
existing footer functions, and 3 lines in `gateway/run.py`.

`turn_seconds` defaults to `None` and the field is skipped when it is `None`
or negative, so any call site that does not measure timing keeps working
unchanged.

`tests/gateway/test_runtime_footer.py` (+185): `_format_latency` boundary
table (sub-second, rounding at 59.4/59.6, the `m{:02d}s` zero-pad, 60m), the
render/skip/opt-in matrix, field-order placement, `build_footer_line`
threading, and the byte-stability block above.

RED-proved by mutation — each of these breaks tests:
- `latency` added to `_DEFAULT_FIELDS` → 11 failures
- dropping the `turn_seconds is not None and >= 0` guard → 2 failures
- `{sec:02d}` → `{sec}` → 6 failures
- `build_footer_line` not threading `turn_seconds` → 1 failure

51 passed in `tests/gateway/test_runtime_footer.py`; 54 passed across the
footer blast radius. `ruff check` clean.

* perf(transport): gate prompt cache keys by provider capability

* feat(transport): imply prompt_cache_key capability for api.openai.com

Review follow-up on the #56798 salvage: the gate shipped fully dormant
(no provider profile sets supports_prompt_cache_key, no production
caller passes it, and no plain 'openai' profile exists to set it on) —
AGENTS.md rejects dead code wired in without E2E proof.

Activate the one endpoint where the field is first-class: exact-host
api.openai.com (OpenAI documents prompt_cache_key; GPT-5.6+ docs
recommend it for cache routing). Deliberately NOT substring matching —
Azure/OpenAI-compat endpoints may reject unknown fields and stay
opt-in via the flag. 4 new tests (imply + 3 spoof/proxy/Azure
negatives); mutation-checked (substring-weakened host check fails the
spoof tests).

* perf(gateway): reuse loaded turn config for timestamp check

Re-derivation of PR #65645 onto current main: _build_gateway_agent_history
already runs inside a turn whose config was loaded once into
ctx.user_config; re-reading config from disk via _load_gateway_config()
per turn is redundant. Reuse the loaded turn config.

* perf(cli): add --prefer-offline to npm install during update (#39267)

Re-derivation of PR #39399 onto current main: pass --prefer-offline to
the web-UI workspace install (both silent and verbose arms of
_install_web_deps) and to the update-time Node dependency refresh in
_update_node_dependencies, so npm reuses its local cache instead of
re-fetching metadata. Test expectations updated to match, mirroring the
PR's own test-update commit.

* perf(cron): skip config load on idle scheduler ticks (idea from #33612)

Re-derivation of #33612 by @LeonSGP43 onto the rewritten scheduler (the
original is 10,692 commits behind; its tick() no longer exists in that
shape, so this is a fresh minimal fix crediting the PR's idea).

The gateway's built-in ticker calls tick(verbose=False) every 60s. The
idle early-return was gated on 'verbose and not due_jobs', so idle
GATEWAY ticks fell through to load_config() + worker-pool resolution
every minute. Return early on ANY idle tick; keep the post-tick MCP
orphan sweep (main intentionally reaps orphaned stdio children on idle
ticks).

3 new tests; mutation-checked (restoring the verbose-gated guard fails
the config-skip test). 66 scheduler tests green.

* fix(feishu): defer the lark_oapi import off the startup path

Salvage of #57657, ported onto the plugin layout (the adapter moved
from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py
since the PR's base). lark_oapi takes seconds to import and holds the
GIL doing it; the module-level import made every gateway boot pay that
cost even with Feishu unconfigured.

- _load_lark_oapi() with double-checked locking binds the SDK globals
  on first use; connect() and _standalone_send() call it via
  asyncio.to_thread so the loop never blocks on the import.
- probe_bot() also calls _load_lark_oapi() (sync context) so the SDK
  probe path is preserved rather than silently degrading to the HTTP
  fallback before a first connect.
- check_feishu_requirements() is install-only and no longer rebinds
  globals; test_feishu.py gets a setUpModule that binds them eagerly
  for tests that inject fake clients.

Includes the dedicated lazy-import test file (check-does-not-import,
connect-loads-on-worker-thread).

* test: bind lark SDK globals session-wide, not per-file

CI exposed the whole class: feishu tests across MANY files (thread
routing, text batching, sdk executor, ...) inject a mock _client and
skip connect(), so the deferred import leaves the request-builder
globals None. Replace the single-file setUpModule with a session-scoped
autouse conftest fixture that binds the globals once when lark_oapi is
installed; when it isn't, the affected tests already skip via their own
skipUnless guards. Full tests/gateway run: zero failures beyond main's
pre-existing baseline (sorted failure-diff).

* fix(feishu): test SDK globals by None-ness, not globals() membership

The no-SDK fallback guards check '"Name" in globals()' — correct on
main where a failed module-level import leaves those names undefined,
but the deferred-import port pre-binds every SDK name to None, so the
guard was always true and the fallback paths called .builder() on None
(AttributeError) wherever lark_oapi isn't installed. Local runs passed
because lark IS installed here; CI's default env has no feishu extra.
Rewrote all 14 guards to 'is not None', which is correct under both
conditions. Verified by simulating CI with a lark-blocking meta_path
hook: 74 passed, 18 skipped (the skipUnless set), zero failures.

* chore: add contributor email mapping for WojtekMR3

* perf: replace COUNT(*) with LIMIT-based existence checks

Two places were using SELECT COUNT(*) when they only needed a boolean:
- has_any_sessions() called session_count() > 1 (full table scan)
- delete_session() used SELECT COUNT(*) WHERE id=? (full matching scan)

Fix:
- Add session_count_ge(n) to SessionDB — short-circuits via
  SELECT 1 FROM sessions LIMIT n, returns bool
- has_any_sessions() uses session_count_ge(2) instead of session_count() > 1
- delete_session() uses SELECT 1 ... LIMIT 1 with fetchone() is None
- Add tests for session_count_ge

* fix(state): take the connection lock in session_count_ge + document archived semantics

Review fold-ins on top of #56768 (@Skywind5487):
- session_count_ge ran its query without self._lock, unlike every
  sibling counter on SessionDB (session_count, session_count_by_source).
- Document the deliberate semantics change: session_count() defaults to
  archived = 0, which is both the expensive part (full index scan,
  measured 543us vs 4us on 20k sessions) and wrong for the only caller
  (has_any_sessions asks 'has this install ever had sessions' -- an
  archived session is still a created one).

* perf(state): index assistant tool-call rows for Insights queries

InsightsEngine._get_tool_usage and _get_skill_usage scan messages for
role='assistant' AND tool_calls IS NOT NULL, but no index aligns with
that predicate, so SQLite scans the full messages table on a large
state.db. Add a partial index over exactly those rows.

role and tool_calls are base columns in the messages table, so the index
lives in SCHEMA_SQL (created on both fresh and existing databases via the
executescript on every open) rather than DEFERRED_INDEX_SQL.

Adds schema regression coverage (fresh + reopened DB, plan uses the index)
and an Insights regression test proving tool/skill output is identical with
and without the index present.

Fixes #67341

* perf(insights): pin partial index on assistant tool-call queries

Review follow-up (#67341): on a freshly initialized state.db (before
ANALYZE has run) the source-filtered branches of _get_tool_usage /
_get_skill_usage did not select idx_messages_assistant_calls_by_session
— the optimizer drove from idx_sessions_source_id and probed each
session's messages via idx_messages_session_active, scanning non
tool-call rows. Pin the index with INDEXED BY on all four fixed-predicate
branches so the plan is deterministic for both the unfiltered and
source-filtered scopes without depending on statistics.

Safe because the index is declared in SCHEMA_SQL (created by every
read-write SessionDB._init_schema) and every InsightsEngine caller opens
a read-write SessionDB; read-only attachments (which skip schema init)
are never used for insights.

Extract the four queries into class constants and add tests: query-plan
coverage for both scopes without ANALYZE, row-level equivalence between
pinned and un-pinned forms, and an assertion that INDEXED BY fails loudly
if the index is absent.

* fix(insights): fall back to unpinned queries when the partial index is absent

The INDEXED BY pin is a hard dependency -- SQLite raises 'no such
index' when the named index is missing. That happens in production:
the web dashboard's usage analytics (_get_usage_analytics,
_get_models_analytics) open state.db read_only=True, which skips
_init_schema, so a DB last written by a pre-index version has no
idx_messages_assistant_calls_by_session and every insights call
crashes with OperationalError (reproduced E2E).

Probe sqlite_master once in __init__ and strip the pin from the four
prepared statements when absent -- identical rows, optimizer-chosen
plan, no crash. Replaces the change-detector test that froze the
crash as intended behavior with a fallback-equivalence test.

* refactor(insights): strip INDEXED BY pins via an attribute loop

Simplify-pass fold: the four copy-pasted .replace blocks meant a\nfifth pinned statement could forget its strip line — a hard 'no such\nindex' crash on read-only DBs, the exact bug the fallback prevents.\nLoop over the attribute names instead.

* perf(state): batch compression-tip row fetch in list_sessions_rich

list_sessions_rich()'s compression-root projection called
_get_session_rich_row() once per root — a separate single-row query per
compression root on every session-list render. Resolve every tip id
first, then fetch all tip rows in one WHERE id IN (...) query via the
new _get_session_rich_rows_batch().

_get_session_rich_row() is now a thin wrapper over the batch method, so
the enriched SELECT (preview + last_active) lives in exactly one place —
future column changes (e.g. #42196's include_system_prompt) only touch
one query.

get_compression_tip()'s chain walk is untouched; it's a genuine
per-session graph walk with branch/delegate-exclusion and race handling,
and batching it safely is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(state): guard compact_rows threading through batched tip-row fetch

Adds two regression tests for the #59077 batch: (1) _get_session_rich_rows_batch(compact_rows=True) uses the schema-derived compact projection (no system_prompt, git_branch/git_repo_root kept); (2) list_sessions_rich(compact_rows=True) threads compact_rows through the compression-tip projection call site. Mutation-checked: hardcoding compact_rows=False at the call site fails test 2.

* refactor(state): chunk the batched tip-row IN clause at 900 ids

Simplify-pass fold: SQLITE_MAX_VARIABLE_NUMBER is 999 on pre-3.32\nbuilds (which the repo still supports — the trigram-availability\nmachinery exists for exactly that class), and limit=10000\nlist_sessions_rich callers exist in web_server. Chunk inside the\nbatch helper — the single choke point — so no call site can overflow.

* fix(yuanbao): clear _processing_msg_ids/_processing_msg_texts after each message

_dispatch_inbound_event() writes session_key → msg_id/raw_text into
_processing_msg_ids and _processing_msg_texts so RecallGuardMiddleware
can find and interrupt the currently-processing message.  These entries
were never removed after a message finished processing, causing both
dicts to grow unboundedly — one persistent entry per unique session key
for the lifetime of the bot.

Fix: clear both entries in the _process_message_background() finally
block, after super() returns.  The guard compares the stored msg_id
against event.message_id before popping: a concurrent pending message
may have already overwritten the entry in _dispatch_inbound_event while
we were running, in which case the drain task owns it and we must not
clear it.  When msg_id is absent (nothing was written at dispatch time)
the pop is a safe no-op.

Note: _msg_content_cache already bounds itself to 200 entries at the
same write site; _processing_msg_ids and _processing_msg_texts had no
such bound.

* fix(yuanbao): evict stale entries from _member_cache on TTL expiry

_build_msg_body_with_mentions() checks the TTL of each _member_cache
entry and returns an empty member list when the entry is stale, but
never removes the entry from the dict.  Over time every group_code the
bot has ever queried accumulates a permanent entry, retaining the full
member list (potentially thousands of records per group) until
disconnect().

Fix: delete the stale entry at the point it is detected as expired.
The next call to get_group_member_list_raw() for the same group will
repopulate the cache with fresh data as before.

Symmetric with the existing TTL pattern in MessageDeduplicator, which
evicts on access.

* fix(yuanbao): pop tracking entries only for truthy matching msg_id + regression tests

Follow-up on the salvaged pair: the original guard's `not msg_id` arm let an
id-less internal/synthetic event erase a tracking entry a concurrently-queued
id-bearing message's drain task still needs for recall matching (id-less
events never write entries in _dispatch_inbound_event, so they must never
pop). Tests cover: normal cleanup, id-less non-erasure, overwritten-entry
ownership handoff, TTL eviction + fresh-entry survival.

* chore: add frizikk to AUTHOR_MAP

* perf(zai): parallelize endpoint detection probes

Z.AI has separate billing for general vs coding plans and global vs
China endpoints. On startup, detect_zai_endpoint() probes up to 4
endpoints sequentially with 8s timeout each, taking 8-9 seconds when
the first endpoints return non-200 (rate limited) before a working one
is found.

Replace the sequential loop with concurrent.futures.ThreadPoolExecutor
to probe all 4 endpoints in parallel. Results are returned in
ZAI_ENDPOINTS priority order so the preference chain is preserved.

Benchmark on macOS M4 Max, Python 3.11, Hermes v0.8.0:
  Before: 8.8s (sequential: global=0.9s/429, cn=1.6s/429,
           coding-global=4.3s/200, coding-cn=2.0s/200)
  After:  ~4.5s (single round-trip, bounded by slowest endpoint)

Signed-off-by: Merlin <merlin@merlin.me>

* test(zai): cover parallel-probe contracts + restore candidate-model loop

Rebase fold: the original PR predates ZAI_ENDPOINTS growing per-endpoint
probe_models lists; the parallel worker now preserves that candidate-model
fallback loop (was: scalar model). Tests (both mutation-checked):
- candidate-model fallback within one endpoint worker
- ZAI_ENDPOINTS priority order wins over completion order
- all-fail returns None

* perf(zai): early-exit when the highest-priority endpoint wins (simplify finding)

The as_completed drain + `with` join made the parallel version WORSE than
sequential main in the common case (first endpoint succeeds fast, others
slow/unreachable): main returned at first success, the parallel version
waited for every straggler. Now: after each completion, walk endpoints in
priority order and return as soon as a success is unbeatable (all
higher-priority probes already finished); pool uses shutdown(wait=False) so
losers drain in the background. Mutation-checked: removing the early exit
makes the new timing test fail (8.2s vs <1.5s).

* Bound MiniMax OAuth error responses

* fix(minimax-oauth): read streamed error bodies inside the client context + real-transport tests

Follow-ups on the salvaged bounded-read fix:
- refresh flow: the non-200 branch reads a STREAMED body, which fails
  (ReadError/StreamClosed) once the httpx.Client context has exited —
  moved inside the context. Repro + regression test use a real socket
  server (MockTransport buffers in memory and cannot catch this).
- truncation guard: >limit bodies end with ...[truncated] (mutation-checked
  against the is_stream_consumed fallback).
- test mocks now model the streamed-read surface (is_stream_consumed,
  iter_bytes, client.send) so non-200 paths exercise the real bounded read.

* chore: map xaydinoktay@gmail.com to aydnOktay

* chore(contributors): map four B2 salvage author emails (#77641)

unixwzrd.register@mac.com -> unixwzrd (#74679); dai.suzuki.829@gmail.com -> hariNEzuMI928 (#75395); lexharddrive69@gmail.com -> hdd69 (#38470); coder@trevhome.local -> trevornk (#76282). Needed for the B2 desktop-renderer salvage attributions.

* perf(session-search): project fields before enrichment

* test(session-search): guard projected enrichment

* fix: skip memory prefetch on trivial user prompts (greetings)

Salvage of PR #25350 (commits 88ffede2d + 2b848a0b2 + 3136dc63a, squashed
and ported): the run_agent.py prefetch site the PR gated has since moved
into agent/turn_context.py's build_turn_context(), so the trivial-query
gate lands there instead.

- Gate the per-turn memory_manager.prefetch_all() on a trivial-prompt
  check so greetings/acknowledgements ('hi!', 'thanks', 'ok') no longer
  block the turn on provider network round-trips or inject stale context.
- Extend honcho's _TRIVIAL_PROMPT_RE with greetings and a trailing
  punctuation class so 'hey!' / 'hello.' classify as trivial.
- Add honcho classifier tests for greeting forms.

* chore: add ayushere to AUTHOR_MAP

* refactor(memory): single shared trivial-prompt classifier + gate tests

Rebase fold on the salvaged gate:
- is_trivial_prompt/TRIVIAL_PROMPT_RE move to agent/memory_provider (the
  ABC both the core gate and providers already import) — one source of
  truth; honcho's _TRIVIAL_PROMPT_RE now aliases it, turn_context and the
  queue_prefetch_all warm path (a sibling site main grew after the PR's
  base) both use it
- tests: gate tests at the prefetch call site (mutation-checked), shared
  classifier tests incl. prefix-collision guards (k8s/yolo/note/supper),
  and honcho dialectic-machinery tests re-driven with a substantive prompt
  ("hello" became trivial by design — those tests exercise thread cadence,
  not the classifier)

* refactor(honcho): delegate _is_trivial_prompt wholly to the shared classifier

Simplify-pass finding: sharing only the REGEX left the wrapper logic
(empty/strip/slash checks) duplicated, half-defeating the no-drift goal.
The classmethod now calls agent/memory_provider.is_trivial_prompt directly;
_TRIVIAL_PROMPT_RE stays as a class attr for backward compatibility with
any external referents.

* fix(desktop): measure adaptive stream flush through the deferred commit frame

scheduleDeltaFlush's adaptive floor is driven by lastFlushCostRef, but
runFlush only timed flushQueuedDeltas(), the synchronous store write.
While a session streams, syncSessionStateToView defers the $messages
publish (React commit + Streamdown re-parse) to its own rAF, so the
measured cost stayed near zero and the floor collapsed to the fixed
33ms path no matter how expensive the real commit was.

runFlush now records the write cost as a fallback, then extends the
measurement through a rAF registered after the view-sync one: it runs
in the same frame right after the deferred commit, and the rAF
timestamp marks frame start so only in-frame work is counted, not the
vsync wait. A stale callback from before a newer flush is ignored, and
a hidden renderer that never fires rAF keeps the write-cost fallback.

* fix(desktop): dedupe optimistic user turns for all wire references, not only images

* test(desktop): cover wire reference normalization edges

* fix(desktop): sort reference-kinds import per lint gate

* fix(desktop): full-jitter backoff on gateway WS reconnect loops

All three desktop reconnect loops (primary gateway boot, secondary
multi-profile gateway pool, plugin event socket) used bare exponential
backoff with no jitter. After a gateway restart every disconnected
client redials on the exact same schedule, so the reconnect attempts
land in lockstep instead of spreading out -- a burst that can starve
the gateway's file descriptors while it's still coming back up.

Add reconnect-backoff.ts implementing AWS-style full-jitter backoff
(random delay in [0, min(cap, base * 2^attempt))) and wire it into all
three call sites in place of their local Math.min/2**attempt math.
Manual reconnect paths already reset the attempt counter and bypass
the timer entirely -- unchanged.

* fix(desktop): escalate gateway reconnect on elapsed time, not attempt count

With the full-jitter backoff (300ms base) six attempts can elapse in ~9s,
so the old RECONNECT_ESCALATE_AFTER=6 attempt threshold raised the
recoverable boot error during a brief post-boot blip — breaking the
'a remote that drops post-boot keeps looping with NO boot.error' contract.
Escalate after RECONNECT_ESCALATE_AFTER_MS (45s, matching the old
deterministic 1->15s ladder's calibration) elapsed since the first failed
reconnect of the episode. Reset on clean open, manual/wake reconnect, and
soft switch, preserving the reset-on-success path.

* chore(contributors): map vittoria3103.123@gmail.com -> VittoriaLanzo (#77665)

Needed for the #62082 curator toolset-pin salvage attribution.

* fix(desktop): un-break the .btn-arc rule — '*/' inside a CSS comment ended it early

The comment above .btn-arc contained 'bg-*/', whose */ terminated the comment mid-sentence, leaving 'text-* variant utilities. */ .btn-arc {' as an invalid prelude — CSS error recovery can drop the whole .btn-arc rule. Reword so no */ appears inside the comment.

Extracted from #59352 by @rerdi92 (the rest of that PR — a month-stale icons.ts rewrite and a chunk-size warning-ceiling bump — is superseded/masking and was not salvaged).

* perf(desktop): stop idle chat re-renders — memo ChatView, stable tile props, gated adapter re-sync

Re-derive of PR #38470 on today's main (its target file desktop-controller.tsx no longer exists after the contrib/ refactor; the three surviving ideas are applied at their new homes):

- incremental-external-store-runtime: the dep-less setAdapter effect ran every render; gate on [runtime, store] — behavior-preserving because __internal_setAdapter early-exits on identical store.
- ChatView is now memo()d, and session-tile hoists its inline arrow props to useCallbacks/module constants so the memo actually holds.
- Render-count regression test (mocked Thread) proves an unrelated parent re-render no longer re-renders the chat shell.

Credit: idea and original implementation by @hdd69 in #38470.

* perf(curator): trim dead tool-schema from the LLM review fork

The curator LLM review loop (_run_llm_review) built its AIAgent without
enabled_toolsets, so it advertised the full default catalog (~30 tools plus the
context_engine lcm_* family) on every call. The fork uses only four tools, fixed
by its own system prompt, with no dispatch path to the rest, so ~26 tool schemas
shipped on every request as dead weight: ~7K input tokens per call on a loop that
makes 50-100 calls per consolidation pass.

Restrict the fork to enabled_toolsets=["skills", "terminal"], the same tools the
prompt already names. Behavior-neutral: the prompt held the model to these tools
and nothing routed calls to the others. Mirrors the background_review fork
(background_review.py:788-794). Call-site only; AIAgent already forwards the kwarg.

Adds test_review_fork_restricts_toolsets_to_skills_and_terminal (captures the
constructor kwarg) and test_review_fork_toolset_surface_is_skills_plus_terminal
(pins the resolved surface).

* fix(desktop): keep a mid-turn reply on screen when its session is reopened

Switching sessions while a turn streams (or right as it completes) could
leave the assistant reply missing until restart. Resume merges stored
history with the gateway's `inflight` projection, whose assistant row is
text-only and often an empty `assistant-stream-${sessionId}` shell; both
reconcile paths then dropped the local pending row that held the only copy
of the streamed text, reasoning and tool calls.

A shared pair of guards replaces the ad-hoc comparisons at all three sites.
`localPendingSupersedes` accepts the cached row only when it is the same
reply further along — an empty shell it has content for, or text it strictly
extends — so a longer unrelated row can no longer hijack an ordinal or reuse
a stream id, and a retained `inflight.error` snapshot is never mistaken for
an empty shell. `withAuthoritativeTurnState` then takes content from the
renderer while liveness, row id and reactions stay the backend's call, so a
settled shell cannot leave a finished reply spinning.

Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com>

* fix(desktop): stop a finished reply rendering twice after history catches up

When a turn's reply commits under its own id, the settled local
`assistant-stream-*` row shifts one assistant ordinal earlier, so ordinal
pairing finds nothing at its slot and re-appends it — the same answer twice.

Drop a settled stream row only when the authoritative transcript already
carries that exact text. Keying `isPendingAssistant` on the explicit pending
flag alone would also have fixed this, but it discards the sibling case in
the same report: a reply that finished locally before the gateway committed
it, where the local row is the only copy that exists.

Co-authored-by: Dolverin <59100064+Dolverin@users.noreply.github.com>

* chore(contributors): map two B3 salvage author emails (#77685)

abdulsalamalotaibi86@gmail.com -> carbongotfound (#74025); soundbrokaz@kakao.com -> JeremyDev87 (#72813).

* refactor(desktop): hoist the reference-line matcher; drop dead textWithoutImageRefs

Follow-up to #77653: textWithoutReferenceLines built a fresh /g RegExp per call and hand-managed lastIndex — but it runs on both sides of every message comparison in the reconcile loops. An anchored non-global regex has no shared-lastIndex hazard and can be hoisted to module scope. Also removes textWithoutImageRefs, whose last production consumer #77653 replaced (kept IMAGE_REF_LINE_RE for extractImageRefs), and retargets its now-stale comment.

* fix(desktop): do not sandwich structured mid-turn rows with inflight dump

Skip pure-text inflight.assistant projections when the transcript already
has reasoning/tool-call structure, and only overlay journal answer text
on strict extension.

Fixes #76444

* fix(desktop): scope inflight dump suppression to the live turn tail

Only skip/graft structure for the current live assistant (stream id,
pending, or after the latest user), not completed historical tool rows.
Require live-tail identity for same-turn structure carry. Align journal
overlay with strict answer-text extension.

Addresses review + CI on #76744.

* fix(desktop): require structure-bearing row for live-tail same-turn carry

Structure-only same-turn carry used (live(previous) || live(message)), so a
new live text-only assistant at a compression-rewritten ordinal could inherit
reasoning/tool parts from an unrelated historical structured row.

Require the structure-bearing cached row itself to be live-tail (pending /
assistant-stream-* / interim). Add regressions for non-extending live dump
carry and the compression graft rejection.

Addresses salvage path on #76744 / #76444.

* refactor(desktop): one live-tail vocabulary for transcript reconciliation

Two fixes landed overlapping helpers on the same statement: the mid-turn
reply guard grew `isLiveProjectionRow` / `hasStreamedContent`, while the
inflight-dump guard grew `isLiveTailRow` / `hasStructuralParts`. Two
definitions of "is this row live" and "does it carry content" in one
function is how the next change silently reshapes one of them.

Collapse to a single module-level pair. `isLiveTailRow` now covers pending,
stream ids, inflight projections and sealed interim rows, so the reply guard
also stops treating an interim row as committed history; `hasStreamedContent`
is defined in terms of `hasStructuralParts`. Both text-extension checks route
through `isStrictAnswerTextExtension` rather than a bare `startsWith`.

Also hoists the live-tail lookup out of an inline IIFE and fixes the lint
warnings it carried.

Co-authored-by: 686f6c61 <github@00b.tech>

* fix(dashboard): cache plugins hub payload and avoid auth probes

* test(dashboard): cover install-hook invalidation of plugins hub cache

* fix(dashboard): warm cold check_fn verdicts with a background probe

On dashboard-only sessions nothing else executes check_fn warmers (they
live only in the tool-schema build), so the hub's read-only cache lookup
would report auth_required=False forever. On a cache miss, schedule a
deduplicated daemon-thread probe off the request path; the short hub TTL
surfaces the verdict on the next fetch.

* fix(desktop): cancel the pending commit-cost measurement rAF

Follow-up to #77652: each runFlush registered a fresh requestAnimationFrame and never cancelled it. Chromium parks rAF callbacks for hidden renderers, so a long hidden stream at the 33ms floor accumulates thousands of parked closures that all fire in the first frame on refocus (all but one no-oping through the stale-frame guard). Track the pending handle, cancel it before requesting a new one (only the newest flush's measurement matters), and cancel on unmount.

* perf(dashboard): skip full InsightsEngine on /api/analytics/usage (#18511)

* perf(dashboard): keep tools in focused analytics usage (#18511)

* refactor(insights): drop consumer-less get_skill_breakdown alias (simplify-pass)

The 2-line alias had zero production consumers (web_server calls
get_usage_breakdown directly). Tests rewired onto the real API; the
contracts they pin are unchanged. Stale test docstring fixed.

* fix(web): clamp dashboard pagination and analytics-days params (#39200 + #74778 salvage)

Re-derivation of aydnOktay's twin clamp PRs onto current main (the
session-list endpoints moved into web_routers/; the analytics endpoints
gained asyncio.to_thread wrappers since the originals):

- limit le=100 on /api/sessions, /api/sessions/search and the
  /api/profiles/sessions fan-out (one unbounded request could drag every
  session row + correlated-subquery preview work out of SQLite, times
  every profile's state.db on the fan-out).
- days ge=1 le=365 on /api/analytics/usage + /api/analytics/models
  (huge or non-positive values force full-history InsightsEngine work or
  inverted windows; the UI only offers 7/30/90 presets).

FastAPI Query bounds reject at the validation layer (422). 8 new tests;
both clamp classes mutation-checked (clamp removed -> its tests fail).

* fix(clamps): raise profile fan-out limit to le=500 (simplify-pass finding)

le=100 would 422 real desktop callers: sessions-settings fetches
archived at limit=200, the command palette lists at 200, and the
electron remote-merge over-fetches limit+offset (exceeds 100 at
offset>=81, and its .catch(()=>null) silently drops remote sessions).
Clamp must sit above real client maxima. New test pins limit=200 w/
offset.

* fix(web): avoid blocking provider validation

* perf(plugins): seed plugin routes from sessionStorage cache for instant render

- Plugin manifests are now cached in sessionStorage on fetch.
- On refresh, plugin routes are registered synchronously from cache, preventing unwanted redirects to /sessions.
- Removes the !pluginsLoading guard from the catch-all route in App.tsx, as plugin routes are now always available on first render.
- Background fetch always updates the cache and routes, so new/removed plugins are reflected after reload.
- Resolves the race condition where plugin pages would redirect to /sessions on hard refresh.

* fix(plugins): validate cached manifests are an array

* test(plugins): export cache helpers and add focused fallback/refresh tests

* fix(plugins): keep loading gate when cached manifests include a /chat override

The sessionStorage seed set loading=false whenever any cache existed, which
defeats App.tsx's load-bearing pluginsLoading gate: with a cached manifest
that declares tab.override === "/chat", the persistent ChatPage host must
NOT mount before plugins resolve, or it spawns a PTY and gets yanked when
the override plugin takes over the route.

Seed loading=false from the cache only when no cached manifest overrides
/chat (canSeedLoadedFromCache); manifests are still seeded either way so
plugin routes register synchronously on refresh. Adds focused tests for
the gate, including the /chat-override case.

* perf(dashboard): serve hashed /assets bundles with immutable cache headers

Every hashed bundle chunk under /assets/ was served with no caching
directives, so each dashboard load re-fetched (or at best revalidated)
every JS/CSS chunk. Those filenames carry a Vite content hash — the
bytes behind a given URL can never change; a rebuild mints new
filenames referenced by a freshly served index.html.

Mark them Cache-Control: public, max-age=31536000, immutable:
- the /assets StaticFiles mount, via a subclass that stamps the header
  on 200s only (404s stay uncached — a rebuild can create the file),
- serve_css, preserving its X-Forwarded-Prefix url() rewrites for
  /fonts/, /fonts-terminal/, /ds-assets/, /assets/.

index.html keeps no-store, no-cache, must-revalidate — it is the
mutable entry point that binds users to the current hashes.

The original PR also added hand-rolled per-request gzip compression of
asset responses; that part is deliberately dropped. This server is a
localhost-default dashboard backend: compressing every response on the
CPU to save loopback bandwidth is a pessimization, and callers that
front it with a real proxy already get compression there.

Salvaged from PR #28543 (idea by @sea-monsters; gzip groups dropped as
described above).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: reset-aware primary restore — stay on fallback until the rate-limit window resets

restore_primary_runtime retries the primary every turn once the 60s
transient cooldown clears. For subscription-window limits (Claude
Pro/Max 5h windows, Codex weekly caps) the reset is hours or days away,
so every retry is a guaranteed failure costing two provider switches
and two prompt-cache invalidations per turn.

Add CredentialPool.next_available_at() (earliest reset across exhausted
entries; None when available now or no reset info) and gate the restore
on it: skip while the primary's pool says nobody can serve, restore on
the first turn after the reset elapses. Fail-open: any gate error or
missing reset info falls through to the existing per-turn retry, so
recovery can never be later than today. Cross-provider fallbacks
consult the PRIMARY's pool (not the attached fallback pool), reusing
the loaded pool for the existing rebind to keep auth reads at one per
restore.

* fix(credential_pool): run next_available_at under the pool lock

Review fold on the #67642 salvage: next_available_at() called
_available_entries() — which prunes DEAD entries, syncs tokens, and
persists — and iterated self._entries with no lock, racing concurrent
select()/rotation exactly as has_available()'s comment warns. Wrap the
method body in self._lock and pin it with a non-blocking-acquire probe
test.

* fix(credential_pool): defer single-use-token refresh outside threading lock

select() and acquire_lease() held self._lock during the entire
_available_entries() loop, which for openai-codex and xai-oauth providers
includes a cross-process file lock (_auth_store_lock) plus OAuth token
refresh HTTP POST.  The lock timeout can exceed 20 seconds, blocking all
credential pool consumers across every gateway thread and subagent.

Collect single-use-token refresh entries under the lock, then execute the
refreshes outside it.  On success the refreshed entry is merged back into
the pool and re-selected.  Non-single-use providers (anthropic, nous)
continue refreshing inside the lock since their refresh is a simple HTTP
POST with no cross-process coordination.

* fix(credential_pool): serialize deferred-refresh pool mutations

Review folds on the #71775 salvage (dossier findings 1+2):

- self._lock becomes an RLock and the mutation primitives
  (_replace_entry, _persist) are now self-locking, so the deferred
  single-use-token refresh path — which deliberately runs its
  cross-process flock + OAuth network I/O OUTSIDE the pool lock —
  still serializes its pool mutations against concurrent
  select()/rotation. In-lock callers re-acquire reentrantly.
- Dropped _refresh_pending_entries' redundant second _replace_entry:
  _refresh_entry already merges the refreshed entry internally.

Adds tests/agent/test_credential_pool_deferred_refresh.py pinning both
invariants: select() must NOT hold the lock during the refresh window
(the PR's whole point), and the post-refresh mutations MUST contend on
the lock (blocking-thread probe).

* `hermes sessions optimize-storage` aborts with

```
Error: optimization failed: no such table: messages_fts_trigram
No data was lost. Re-run to resume.
```

on any install where the trigram FTS index is legitimately absent. The failure is
deterministic — re-running can never make progress, because the crash happens at the same
point every time — so the database is permanently stuck on the legacy high-footprint FTS
layout with no supported way forward.

Observed on a 5.4 GB production `state.db`. After the fix the same database optimized
successfully and shrank to 3.3 GB.

The trigram index is absent whenever the runtime cannot maintain it. On a SQLite build
without the `trigram` tokenizer, `_ensure_fts_schema()` returns `False`, so `__init__`
leaves `self._trigram_available = False` and no `messages_fts_trigram` table on disk. This
is a **supported degraded runtime**, not damage — CJK/substring search falls back to
`LIKE` and everything else works normally. `_is_fts5_unavailable_error()` and
`_warn_trigram_unavailable()` exist specifically to make this path graceful.

Two code paths write the boundary sweep for the deferred FTS rebuild, and only one of them
respects that flag:

| Function | Trigram `INSERT` guarded? |
|---|---|
| `fts_rebuild_step()` | ✅ `if include_trigram:` where `include_trigram = self._trigram_available` |
| `_fts_rebuild_finish()` | ❌ unconditional |

`_fts_rebuild_finish()` runs the boundary sweep at the *end* of the backfill. Its
unguarded `INSERT INTO messages_fts_trigram …` raises `OperationalError`, which propagates
out of `optimize_fts_storage()` and aborts the entire optimization — *after* the backfill
has already completed. Hence the characteristic output showing 100% progress immediately
before the error:

```
Rebuilding index: 100% (909,671/909,671)
Error: optimization failed: no such table: messages_fts_trigram
```

There is a second, quieter consequence. The teardown phase that reclaims the demoted
`fts_v22_trash_*` shadow tables runs *after* the backfill phase in
`optimize_fts_storage()`. Because the crash happens before teardown is ever reached, those
tables are never emptied or dropped — so the space the migration was supposed to reclaim
stays allocated indefinitely, and the leftover trash tables look (misleadingly) like
evidence of a half-finished migration.

Build a populated v23 database, set the deferred-rebuild markers, then reopen it on a
runtime where `_ensure_fts_schema('messages_fts_trigram', …)` returns `False` (exactly
what a SQLite build without the trigram tokenizer produces) and call
`optimize_fts_storage()`:

```
[precondition] trigram absent, _trigram_available=False, rebuild pending  ✓

RED  ✗ optimize_fts_storage raised OperationalError: no such table: messages_fts_trigram
```

With this patch applied, unchanged harness:

```
     optimize_fts_storage returned {'ok': True, 'vacuumed': None}
GREEN ✓ optimize ok; markers cleared; base FTS 'zebra' -> 200 hits
```

Full harness and transcripts in `TEST-EVIDENCE.md`.

Gate the sweep on `self._trigram_available`, exactly as `fts_rebuild_step()` already does:

```python
include_trigram = self._trigram_available

def _do(conn):
    ...
    if include_trigram:
        conn.execute("INSERT INTO messages_fts_trigram(...) ...")
```

The base `messages_fts` sweep and the marker cleanup are untouched, so the rebuild still
finalizes correctly and the index remains complete for every row it is responsible for.
The fix does not disable or weaken search to dodge the error — the regression tests assert
that base FTS still returns results afterwards.

`TestFtsRebuildFinishWithoutTrigram` in `tests/test_hermes_state.py`:

- `test_rebuild_finish_skips_trigram_when_unavailable` — drives `_fts_rebuild_finish()`
  directly on a trigram-less runtime; asserts it completes, clears both rebuild markers,
  and leaves base FTS searchable.
- `test_optimize_fts_storage_succeeds_without_trigram` — end-to-end through the public
  `optimize_fts_storage()` entry point; asserts `ok=True`, markers cleared, search intact.

Both use the existing `_NoTrigramConnection` helper already in the file. Both fail on
`main` with `no such table: messages_fts_trigram` and pass with this patch.

`tests/test_hermes_state.py` passes in full (463 tests → 465 with these two). `ruff` clean.

This PR is the crash only.

A companion PR narrows `_db_opens_cleanly()` so that
`hermes sessions repair --check-only` stops reporting a write-broken FTS schema as
healthy — the gap that makes this class of problem hard to diagnose in the first place.
The two are independent and can land in either order.

* perf(tools): shrink lazy tool catalog overhead

* refactor(tool-search): drop dead fallback ladder in _available_source_summary

Simplify-pass finding: _listing_group_label already falls back to 'other' for empty source names, and _classify_source guarantees source_name=='' only when source=='other' — both  legs were dead by construction. Aligns the summary path's grouping with the listing path.

* fix(credential_pool): unpack the tuple in next_available_at's gate

Cross-PR interaction fix: #77714 (salvage of #71775) changed
_available_entries to return (available, pending_refresh) while #77631
(salvage of #67642) added next_available_at() which still truthiness-
tests the bare return. A non-empty tuple is always truthy — even
([], []) — so the reset-aware gate silently returned None ('no wait
info') for every exhausted pool, disabling the feature #77631 shipped.
Unpack the tuple and test the available list.

Also adapts the lock-probe test for the RLock introduced by #77714
(same-thread non-blocking acquire always succeeds on an RLock; probe
from a helper thread instead).

* chore: map copii.list@gmail.com to stremtec

* chore(contributors): map marzukia@users.noreply.github.com -> marzukia (#77774)

Needed for the #37117 salvage (#77696 CI failure).

* chore: map bot@bkstock.dev to BKStock

* perf(session): route SQLite PRAGMAs through central apply_database_pragmas

Addresses review from @teknium1 on PR #71755:

- Extended apply_database_pragmas() to handle cache_size, mmap_size,
  and temp_store from config.yaml (alongside existing wal_autocheckpoint
  and journal_size_limit). No hardcoded defaults — all values are
  opt-in via config.yaml, avoiding policy conflicts with other PRs.
- Applied to ALL connection types: writer (_connect_and_init),
  read_only cross-profile attach, and WAL per-thread readers
  (_get_read_conn). Previously PRAGMAs only ran on the writer path.
- Removed inline PRAGMAs from _connect_and_init — single source of
  truth in apply_database_pragmas().
- Documented config keys with examples in function docstring.

* fix(pr): remove remnant local PRAGMAs from PR branch

* test(session): guard config-gated performance PRAGMAs across all connection types

E2E guard for the salvaged PR #71755: database.cache_size/mmap_size/
temp_store from config.yaml must reach the writer connection, the
read-only cross-profile attach, and the WAL per-thread reader — and a
default install (no database: keys) must keep byte-identical SQLite
defaults on every connection type. Also covers integer-coercion
rejection of garbage values for the three new keys.

cache_size uses -16000 (not the doc example -2000) because -2000 is
SQLite's compiled-in default and would not discriminate a regression.

* fix(desktop): flush queued deltas on window focus

* perf(desktop): stop scroll and status loops in busy sessions

* perf(desktop): pause hidden-pane timers in agents view, cron sidebar, and floating pet

Partial pick of the surviving renderer hunks from #75395 (perf commit
6502e441d plus fixup 3fbbc9c1d): gate the 500ms subagent now-ticker and
the cron sidebar 1s ticker/run-poll on usePaneVisible, and skip the
legacy floating-pet poll while the document is hidden. Dropped hunks
(electron/main.ts, vitest.setup.ts/config) intentionally excluded.

* style(desktop): restore alphabetical import order in agents/index.tsx

* refactor(desktop): shared pulse beat + fully-gated cron peek (simplify folds)

Two findings from the simplify pass on the final trio diff:

- status-pulse: one pause controller + one aligned period timer shared by all StatusPulse instances (ref-counted), instead of N x (document/window/bridge listeners + unsynchronized 5s wakes) — a sidebar can show dozens of pulsing dots. Pause still cancels in-flight animations so the compositor sleeps immediately.
- cron-jobs-section: the runs-peek effect created its interval even while the pane was hidden (callback no-oped but the timer still woke the renderer every 8s/60s per expanded job). Early-return when hidden — visibility is already in the dep array, so becoming visible restarts load + timer.

* fix(lint): import sort + eslint-disable for timer-handle ref clear in effect

CI-caught: cron-jobs-section had an extra blank line between sorted imports; use-message-stream's visibility-flush effect assigns flushHandleRef.current=null inside a useEffect (legitimate timer-clear, not an atom mirror) — eslint-disable-next-line per the rule's documented convention.

* fix(state): narrow FTS UPDATE triggers with AFTER UPDATE OF + migration

Retarget #73639 onto the SessionDB mixin split (hermes_state_common /
hermes_state_schema). Fresh installs create UPDATE OF content/tool_*
triggers; existing broad AFTER UPDATE triggers are inspected and
replaced under schema init without an FTS rebuild (WHEN clauses already
guarded content correctness; OF skips non-content status writes that
saturated disk I/O on large state.db).

Tests: tests/test_fts_update_of_narrowing.py (4)

* fix(state): fail closed on CJK trigger migration

* fix(state): quarantine CJK when ensure soft-fails after OF migration

_ensure_fts_cjk_schema never raises on OperationalError; post-condition
after dropping messages_fts_cjk_update now requires a narrowed UPDATE
trigger or durable fts_cjk_stale + unavailable. Covers the production
soft-fail path the raise-only handler missed.

* refactor(state): drop unreachable regex guard in trigger migration

Simplify-pass fold: to_drop names come from the literal update_names\nallowlist via IN binding, so the [A-Za-z0-9_]+ fullmatch could never\nfail — and if it somehow did, its `continue` would miscount (the\nskipped trigger stayed in len(to_drop)/the log while CREATE TRIGGER\nIF NOT EXISTS silently kept the broad variant). Delete the guard and\nits function-local re import; keep the invariant as a comment.

* fix(security): reject always-blocked OpenViking endpoints

## Summary
- Normalize OpenViking endpoints through `is_always_blocked_url` and fall back to the default local endpoint when poisoned.
- Keep intentional loopback / LAN self-host working.
- Add focused unit tests.

## Salvage / credit
Memory-provider endpoint floor sibling of RetainDB/Supermemory always-blocked hardening (avoids over-broad #4984-style private-IP bans).

(cherry picked from commit 8fa607d0aedb8c5fca398d7f112b1b25ade54fa2)

* fix(openviking): fail closed on blocked endpoints

(cherry picked from commit 389a90b81c9c2c89810f2fa7461f8faa9a5c9578)

* fix(openviking): don't spawn a second server onto a live port

`_start_local_openviking_server()` spawned `openviking-server`
unconditionally. Both callers — `initialize()` and the runtime
unreachable handler — reach it from a health probe, and that probe can
time out client-side while the server is up and serving. The spawned
process then loses the data-directory lock and exits immediately with
`DataDirectoryLocked`; because the probe keeps timing out, the cycle
repeats every cooldown window (~5 min observed).

The existing 30s `_failed_refresh` cooldown paces the loop but cannot
stop it, since it expires while the underlying condition persists.

Probe the target host:port before spawning and treat an occupied port as
already-started. This guards both call sites at their single convergence
point. The probe deliberately tests only that a listener owns the port —
enough to know a second server would lose the lock — and says nothing
about that listener's health.

The parse/probe now precedes the PATH lookup, so a reachable server is
reported as running even when `openviking-server` is not on PATH.

Fixes #74846

(cherry picked from commit b49427d85fd6628eb4a7fe099e5c390c5c4cc935)

* fix(openviking): drop stale "disabled for this Hermes run" warnings

The provider used to disable OpenViking permanently when the server was
unreachable. That was fixed: `_ensure_client()` now reconnects lazily,
with a 30s cooldown gate in `_ensure_client_locked`.

Only one of the seven user-facing warnings was updated to match. The
other six still told the user memory was "disabled for this Hermes run",
which is no longer true — every one of those paths is retried on the next
access. A user who reads the old message has no reason to retry, which is
very likely how #5721 ("never recovers") came to be filed against
behaviour that already recovers.

All six sites were traced to confirm none is terminal for the run: the
`initialize()`-time and waiter-thread failures never arm `_failed_refresh`
(only line 2439 does), so they retry on the very next access with no
cooldown at all.

The replacement wording deliberately omits the "(after cooldown)"
parenthetical used at the already-correct site — that detail is only
accurate where `_failed_refresh` was just armed. The neutral phrasing is
true at all six.

Also promotes two clause separators to periods to avoid "…; …disabled;"
collisions.

(cherry picked from commit 8346403a4b97af503d26b0f7905ff513828d821e)

* fix(openviking): re-arm the commit guard after in-place compression

`_committed_session_ids` is a permanent per-sid latch, and
`_session_needs_commit` checks it before the turn counter by design — a
racing sync_turn can re-increment `_turn_count` after commit+reset, so
the guard must win to stop a double-commit.

That is correct for a session being left behind. It is wrong for one
that keeps its id. `compress_context()` commits before rewriting the
transcript in both modes, and with `compression.in_place: true` (the
default) `on_session_switch` receives the same id and does not rotate.
The latch then rejects every later commit for a still-live session — the
next compression, /new, normal session end, startup recovery — so every
post-compression turn is silently never extracted.

Rotation mode is unaffected because a fresh child id is minted and
starts clean, which is what confirms the latch's intent was only ever to
dedupe the departing id.

Clear the latch when compression completes without rotation. Turns
arriving after that point are genuinely new, and this is a defined
moment rather than a race. The rotation path is untouched, so the old
id stays latched and its _finalize_session_async still dedupes against
the compression commit.

Fixes #74695

(cherry picked from commit d1e5c3dc33ef0d43d021662674e1a7cd5e43eecd)

* test(openviking): cover the compression lifecycle, not a hand-set latch

Review feedback: the previous test called _mark_session_committed
directly, so it verified the guard's behavior but not the wiring that
sets it — a future break in the commit_memory_session -> same-id
compression-boundary path would not be caught.

Add a lifecycle regression that drives the real sequence: on_session_end
commits through the actual path, on_session_switch(same id,
reason="compression") crosses the boundary, sync_turn records a genuinely
new turn, and a second on_session_end must produce a second commit POST.

Without the fix it fails showing exactly one commit call, which is the
reported data loss: every turn after the first compression is dropped.
The rotation and /undo tests stay as scope guards.

(cherry picked from commit 0ca5a330630a30b105cbbc32e8a23f2c5ffe0eab)

* fix(memory): read non-secret provider config from config.yaml for OpenViking and RetainDB

OpenViking is_available() only consulted env vars and use_ovcli_config, so an
endpoint saved to config.yaml (e.g. by the Dashboard) reported needs_config;
_resolve_connection_settings() likewise never folded config.yaml's non-secret
fields into its chain. RetainDB initialize() read base_url/project from the
environment only, ignoring the values the Dashboard writes to config.yaml.

Both now resolve non-secret fields as env -> (ovcli ->) config.yaml -> default;
secrets still come from the environment. Adds regression tests for both.

Fixes #68209

(cherry picked from commit dca57915b97b5705b30927a062e1d0f2f23d3841)

* fix(openviking): read recall settings from config.yaml first, env vars as fallback

_recall_config() previously read all settings (recall_limit, score_threshold,
recall_resources, etc.) exclusively from environment variables. This forced
users to store behavioural configuration in .env, violating the Hermes
convention that .env is for secrets only.

The infrastructure to load config.yaml -> memory.openviking was already in
place via _load_hermes_openviking_config(), but _recall_config() never
called it.

Fix: call _load_hermes_openviking_config() and pass its values as the
default parameter to _env_int/_env_float/_env_bool. Env vars still override
config.yaml values, preserving backward compatibility.

Closes #62540

(cherry picked from commit 6aadf1256835745e0302aa3d3b5ae0660b368637)

* test(openviking): cover config.yaml recall settings with temp-HERMES_HOME tests

Add three tests to TestOpenVikingConfigSchema:

1. test_recall_config_reads_from_config_yaml — writes memory.openviking
   settings in config.yaml and verifies _recall_config() consumes them.

2. test_recall_config_env_overrides_config_yaml — writes both config.yaml
   and OPENVIKING_RECALL_* env vars, verifies env takes precedence.

3. test_recall_config_partial_config_yaml — partially populated config.yaml
   falls back to defaults for omitted keys.

All 46 openviking_plugin tests pass (43 existing + 3 new).

(cherry picked from commit b8d7834caf06c6912004333c270faa248eaed4cd)

* fix(openviking): integrate reliability and configuration hardening

* chore(contributors): map OpenViking source authors

* test(retaindb): guard scoped secret config resolution

* fix(openviking): verify servers before sending credentials

* fix(openviking): catch endpoint errors in setup validation functions

Review follow-up for salvaged PR #76782. Three setup-wizard
validation functions called _normalize_openviking_url outside their
try/except blocks. Since _normalize_openviking_url now raises
_OpenVikingEndpointError for blocked or malformed endpoints, an
invalid endpoint would crash the wizard instead of returning a
friendly (False, message) tuple.

- _validate_openviking_auth: move _normalize_openviking_url inside try
- _validate_openviking_root_access: same
- _validate_openviking_setup_values: catch _OpenVikingEndpointError explicitly
- Remove dead ternary in _normalize_openviking_url safety check (candidate
  always has http/https scheme by that point)
- Replace redundant float('-inf') < x < float('inf') with math.isfinite()
  in _setting_float; drop the redundant infinity check from _setting_int
  (is_integer() already rejects inf/nan)

* fix(state): deduplicate session system prompts

* chore: map cicav legacy noreply email

* fix(tui): avoid writable Kanban opens on empty polls

* fix(context): dedupe subdirectory hints by content digest and skip backup/vendor dirs

SubdirectoryHintTracker re-injected identical context files whenever the same
AGENTS.md was reachable through more than one path. Symlinked shared
workspaces, hardlinks, and timestamped backup copies all alias a single file,
so a normal session could ship the same 8KB of instructions two or three
times. Nothing deduped it and nothing excluded directories that only ever
hold copies.

Two changes:

* Track a sha256 of every injected hint body. Repeat content is skipped, and
  the working directory's own context file is seeded at construction so the
  copy prompt_builder already loaded at startup is never sent again.
* Skip directories that hold copies rather than authoritative context
  (backups, node_modules, venv, site-packages, .git, .Trash, vendor, caches).
  Screening is relative to working_dir, so a project that legitimately lives
  under vendor/ keeps discovering its own subdirectory hints.

Measured on a real session that touched a symlinked shared workspace:
3 injections / ~24,000 chars before, 1 injection / 8,112 chars after.

14 new tests cover symlink aliasing, byte-identical copies, working-dir
seeding, distinct content still being injected, each excluded directory name,
excluded ancestors, and the working-dir-inside-excluded-name case.

* perf(state): batch the turn flush into one SQLite transaction

Re-derivation of #23254 (@devsart95) on today's flush loop. The turn
flush in _flush_messages_to_session_db wrote one BEGIN IMMEDIATE
transaction per message row; a typical agent turn (user + assistant +
tool results) paid 3-8 transactions -- and, off WAL (the default on
macOS while the WAL-reset guard is active), 3-8 fsyncs -- per turn.

Adds SessionDB.append_messages_batch: same row shape as append_message
(shared _prepare_message_row serializer + _MESSAGE_INSERT_SQL column
list, so the two writers cannot drift), same compression-lock and
compression-closed guards, one aggregated session-counter UPDATE, one
transaction for the whole batch. Row serialization stays outside the
write lock.

The flush loop now collects the turn's new rows and writes them in one
call. All-or-nothing pairs exactly with the persisted-marker stamping:
on failure no rows landed and no markers were stamped, so the next
flush re-writes the whole tail (same recovery contract as before,
minus the partial-prefix case that could double-count).

Measured (same harness, 5-message turn, journal_mode=DELETE,
synchronous=FULL): 2.32ms -> 0.83ms median per turn flush (64% faster,
5 fsyncs -> 1). On WAL the win is smaller but the atomicity fix holds.

* perf(tui-gateway): batch branch-seed history copies (whole-bug-class)

Sibling sites of the per-message flush pattern: both branch-seed
paths (session.branch in methods_session.py and the lazy seed persist
in server.py) copied the parent history row-by-row -- one transaction
per row, and a branch seed can be hundreds of rows. Route both through
SessionDB.append_messages_batch. The server.py path also gains real
atomicity: _branch_seed_persisted assumed every row landed, which the
per-row loop could not guarantee.

* test(run-agent): update flush-path fakes and assertions for batched writes

The flush now goes through append_messages_batch; MagicMock-based
assertions and barrier fakes that hooked append_message observed
nothing (the flush's try/except swallowed the AttributeError). Assert
on the batch payload instead.

* refactor(state): fold simplify findings — reuse _insert_message_rows, share guards, chunk seeds

Simplify-pass folds on the #23254 salvage:

- REUSE (HIGH): append_messages_batch now delegates row serialization to
  the pre-existing _insert_message_rows helper (already shared by
  replace_messages / archive_and_compact / portability import) instead
  of adding a third serialization path (_prepare_message_row +
  _MESSAGE_INSERT_SQL are gone). One row-writer for every multi-row
  path; the row-ID return was consumed by no production caller, so the
  batch returns the inserted count.

- QUALITY (HIGH): the compression-lock + compression-closed admission
  guards are extracted into _check_transcript_write_guards, shared by
  append_message and append_messages_batch (previously duplicated 23
  lines that had already needed targeted fixes, #74478). The role-gated
  reasoning filtering is no longer duplicated in run_agent.py — it
  lives at its one site inside _insert_message_rows.

- EFFICIENCY (MEDIUM, measured): unbounded seed copies hold one BE…
OmarB97 added a commit to OmarB97/hermes-agent that referenced this pull request Aug 4, 2026
* feat(desktop): reload a tab from its right-click menu

Right-click any tab and pick Reload: the pane's content remounts in
place — effects re-run, state resets, measurements are retaken — while
the tab keeps its slot and every other tab is untouched.

A per-pane epoch atom keys the contribution inside the zone body, so
reload never rewrites the layout tree. Both tab menus offer it: the
zone strip menu (tool panels, the file tree, a fresh draft's main tab)
and the session tab menu (tiles + the loaded main tab).

* fix(approval): classify CLI/TUI approval timeouts separately from explicit denials

When an approval prompt expired without a response, every CLI-side path
collapsed the timeout into the same 'deny' choice as an explicit user
refusal, so the agent was told the user denied the action when the user
simply never answered. The gateway wait already distinguished the two
('timed out without user response... Silence is not consent.'); this
brings the CLI/TUI/ACP surfaces to parity.

- prompt_dangerous_approval(): input()-path expiry now returns a distinct
  'timeout' choice (still fail-closed).
- cli.py _approval_callback + hermes_cli/callbacks.py approval_callback:
  deadline expiry returns 'timeout' instead of 'deny'.
- check_all_command_guards / _run_approval_gate CLI tails: 'timeout' maps
  to outcome='timeout' with a 'timed out without user response... Silence
  is not consent.' BLOCKED message (matching the gateway wording);
  explicit deny keeps outcome='denied' and gains user_consent=False for
  shape parity.
- computer_use: 'timeout' verdict threads through the CLI adapter and
  yields a 'prompt timed out — the user did not respond' error instead of
  'denied by user'.
- ACP permissions bridge: FutureTimeout returns 'timeout' (other failures
  still 'deny'); elicitation maps 'timeout' to 'cancel' like the gateway's
  unresolved outcome; codex wire mapping documents deny/timeout→decline.
- write_approval already treats unknown choices as 'stage, not drop', so
  a timeout now stages the memory write instead of silently refusing it.

Every timeout path remains fail-closed — the action never runs; only the
classification reported to the agent changes.

* fix(desktop): paste rich text with images as text, not blank attachments

Copying a Discord thread (or any rich-text selection with images) attached
one or more blank thumbnails and dropped the message text entirely.

Two causes. The clipboard's `text/html` was scraped for inline
`<img src="data:…">` regardless of whether the copy carried its own text —
and what Discord ships beside each image embed is a 32x5 blurhash
placeholder, which is exactly the blank attachment. Then, because any image
blob short-circuited the paste handler, the prose that came with it never
reached the composer.

Inline HTML images now only count for an image-only copy, and are ignored
below a thumbnail-sized floor so spacers and trackers don't attach either. A
mixed paste attaches its real images and still inserts its text.

Also registers pasteAndMatchStyle in the Edit menu — Cmd+Shift+V had no menu
entry, so the chord was never translated into an editor command anywhere in
the app.

* fix(cli): persist YOLO mode across --resume

A session's YOLO bypass lived only in the in-memory
tools.approval._session_yolo set (or the process-frozen --yolo env
var), so resuming a session in a fresh process silently reverted the
user's /yolo ON — dangerous commands started prompting again.

Persist a yolo_mode flag in the session row's model_config JSON and
restore it on every CLI resume path:

- SessionDB.set_session_yolo() merges the flag into model_config
  (same lineage-preserving merge as update_session_runtime_lock);
  SessionDB.session_yolo_enabled() reads it back, false on any parse
  failure.
- /yolo toggle persists ON and OFF through the new helper; the
  compression/branch session-id rotation carries the flag onto the
  continuation row.
- --yolo launches record the flag at session creation (agent_init),
  and a /yolo toggled before the lazily-created row exists is carried
  into the creation-time model_config (_ensure_db_session).
- HermesCLI._restore_session_yolo() re-enables the bypass on startup
  --resume/-c, the deferred init path, and mid-chat /resume, with a
  visible '⚡ YOLO mode restored from session' notice. No-op under a
  frozen process-wide --yolo and never enables on absent/garbage flags.

* fmt(js): `npm run fix` on merge (#77300)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore: contributor email mappings for egilewski and myk0la-b

* perf(tui): bound scroll rendering and preserve anchors

* chore: AUTHOR_MAP for ckaznocha (matrix crypto PRs)

* chore: add contributor email mapping for LFDMcore (core@lfdm.co) (#77331)

* fix(tui): snapshot history after pending model switch applies (#76870)

Deferred model switches append a marker and bump history_version at
turn start; the dispatcher was snapshotting history before that
mutation, so the version-mismatch guard rejected the turn's own
result as a stale/concurrent write. Move the snapshot to after
_apply_pending_model_switch/_sync_agent_model_with_config, under
history_lock, so the turn's own preparatory mutation is included in
its baseline while the anti-stale guard still catches real external
writes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* chore: drop stale diagnostic report from PR #77021

teknium1 flagged ISSUE_76870_RELATORIO_CAUSA_RAIZ.md as containing
stale metadata (references an unrelated local branch) and asked to
drop the standalone report, keeping only the focused server.py fix
and regression test.

* perf(agent): precompile response and skill-scan regexes (#33208)

strip_think_blocks passed the same response-scrubbing strings through
re's pattern dispatcher on every response. Skills Guard repeated the
same work for 121 patterns against every scanned line.

Compile the existing expressions once and reuse Pattern.sub/search. Keep
each generic tool-call tag in its own paired expression so mismatched
openers retain their payload while existing stray-closer cleanup remains
unchanged.

Part of #33208
Salvaged from #32713 by @ErnestHysa.

Co-authored-by: ErnestHysa <takis312@hotmail.com>

* refactor(agent): rebuild hoisted patterns from shared tag-name tuples

Simplify-pass follow-up on the #69653 salvage: the original code built
these patterns from a name loop; hand-expanding them into 10 literals
lost that single source. Tag-name tuples restore it (adding a 6th
reasoning tag is now a one-place change), and the gnarly named-function
pattern regained a pointer to its step-1c rationale. Byte-equivalence
of every rebuilt pattern verified programmatically (alternation-order
neutrality probed: the \b and > anchors make order irrelevant).

* chore: add ATran28 to AUTHOR_MAP

at828@proton.me -> ATran28 (GH id=1445620)
Needed for PR #77270 whatsapp bridge reconnect fix.

* fix(gateway): defer in-band restart until active turns finish (#77184)

request_restart was calling stop() immediately, so the requesting turn stayed
in the drain wait set and got force-killed at restart_drain_timeout. Wait for
active work to reach zero first, then stop against an idle gateway.

* test(gateway): cover restart after-turn deferral (#77184)

* fix(tui_gateway): drain in-flight turns before finalizing sessions on compute-host shutdown

ComputeHost.shutdown() called flush_all_sessions() before its own in-flight
turn drain loop. server._finalize_session latches on session["_finalized"]
and every later call returns immediately, so that one flush was spent while
turns were still producing output: the unflushed tail was never persisted,
commit_memory_session wrote long-term memory from a truncated transcript, the
session's DB row was marked ended while it was live, on_session_end fired with
completed=False/interrupted=True against a running session, and the
active-session lease was released out from under a turn. The drain loop exists
precisely so that mid-turn work survives a teardown; finalizing first defeated
it. Reachable from all three teardown paths: the parent/orphan guard (which
os._exit(0)s immediately after), the SIGTERM/SIGINT handler, and stdin close.

Drain first, then flush. A slice of the caller's budget (_FLUSH_RESERVE_SECS,
never more than half of it so a short explicit wait still gets a real drain) is
withheld from the drain so the flush still runs when turns outlast the window:
HostSupervisor SIGKILLs the host _SHUTDOWN_TIMEOUT_SECS after SIGTERM — 10.0s,
the same value as shutdown()'s default wait — so a drain allowed to consume the
whole budget would leave the durability write racing that kill. `wait` itself is
unchanged, so total shutdown latency and the SIGTERM->SIGKILL margin are
unchanged.

* fix(tui_gateway): bound the shutdown drain sleep by the time left to it

The drain loop slept a flat 0.05s per tick, so it could overshoot its deadline
by up to one tick and spend part of the reserve withheld for
flush_all_sessions(). For a small `wait` the reserve is itself half the budget,
so a single overshoot can consume all of it: at wait=0.34 the drain budget is
0.17s but the loop requested 4 x 0.05 = 0.20s of sleep.

Clamp each tick to the remaining time. The new test asserts on the summed
*requested* sleep rather than wall-clock, which is deterministic: every sleep is
bounded by the strictly-decreasing remainder, so the total can never exceed the
drain budget regardless of how the scheduler interleaves.

* fix(tui_gateway): retain live-turn sessions unfinalized when the drain deadline expires

The drain reserves a slice of the shutdown budget so flush_all_sessions
still runs when in-flight turns outlast the window. But that flush was
unconditional: a session whose turn was still running got its one-shot
_finalize_session spent mid-turn, and the executor.shutdown(wait=False,
cancel_futures=True) immediately after does not join the turn. The
session was then permanently un-finalizable and its active-session lease
had been released out from under live work — the same persistence and
lifecycle race the drain exists to close, just relocated past the
deadline instead of removed.

Give _turn_futures a session association (Future -> sid, the same key
space as server._sessions) at both submit sites, and on deadline expiry
exclude the sids whose futures are still running from the flush. Those
sessions are retained unfinalized and therefore recoverable; sessions
with no live turn finalize exactly as before. The done-callback now pops
under the lock, since a bare dict.pop is not the drop-in set.discard was.

wait semantics, the reserve math and the bounded per-tick sleep are
unchanged, so this adds no shutdown latency. All three shutdown callers
(orphan, sigterm, and the tight stdin_closed wait=2.0 path) funnel
through this one function and are covered.

* docs: note atexit re-finalization interaction in shutdown() docstring

The PR's skip-live-sessions optimization is partially defeated by
server._shutdown_sessions() registered via atexit (server.py:1172),
which runs on SystemExit after shutdown() returns for the SIGTERM and
stdin_closed paths. The orphan path (os._exit(0)) bypasses atexit.

This is a pre-existing issue — the old finalize-first order had the
same atexit interaction. The comment documents the gap and suggests a
follow-up: gate _shutdown_sessions on not session.get('running').

* fix(desktop): memoize sidebar flatRows and row renderers to prevent scroll jitter (fixes #73629)

Fix direction inspired by PR #73674 by @drbronson with added Vitest component unit tests.

* fix(test): export VirtualSessionListProps and assert sessions array identity change

* style(desktop): eslint --fix import order in sessions-section test

Review follow-up on the #75714 salvage: perfectionist/sort-imports
would fail lint CI; autofixed.

* fix(ui-tui): pause status-chrome timers while a blocking overlay is open

`StatusRulePane` renders `StatusRule` outside the `!isBlocked` guard in
appLayout.tsx, so the status rule stays mounted underneath approval,
model-picker, pager, sessions and every other blocking overlay. Its three
timer-driven components keep firing the whole time: `FaceTicker` (glyph,
1s clock, verb rotation), `SessionDuration` (1s) and `IdleSince` (1s).
Every tick re-renders a rule nobody can see, and in an Ink TUI that churn
reads to the user as the dialog flickering.

Gate all three components' interval creation on the existing `$isBlocked`
computed store, so nothing is armed while an overlay covers the rule.

The pause alone would leave the elapsed read-outs frozen at the moment the
overlay opened, so each effect re-seeds `now` from the wall clock when it
re-arms. `SessionDuration` and `IdleSince` already did this; `FaceTicker`
gains the same re-sync. Closing a five-minute overlay now resumes at the
true elapsed time instead of the pre-overlay value.

No new store is introduced — `$isBlocked` already exists and already ORs
the current OverlayState field set.

* fix(ui-tui): gate status-rule timers on real occlusion, not $isBlocked

`$isBlocked` answers "is text input suspended", not "is the status rule
covered". appLayout uses it only to hide the input rows (appLayout.tsx:384);
`StatusRulePane` renders outside that guard, at :365 for `at="top"` and :449
for `at="bottom"`. So the previous revision paused FaceTicker /
SessionDuration / IdleSince under prompts that leave the rule fully on
screen — approval, billing, subscription, confirm, clarify, sudo and secret
all render through PromptZone in NORMAL FLOW above ComposerPane
(appOverlays.tsx:58-162, appLayout.tsx:553-568). They push the rule down;
they do not cover it. Freezing a visible clock is a worse bug than the churn
being removed.

Replace it with `$isStatusRuleOccluded`, a narrow derived store over
overlay + ui state covering only what actually paints over the rule:

- `widget` — the modal widget slot renders at viewport level
  (ActiveWidgetSlot, sdk/host.tsx:209) so it can anchor the full-screen
  absolute `Overlay` against the whole terminal.
- the FloatingOverlays set (modelPicker, pager, petPicker, sessions,
  skillsHub, pluginsHub) — but only when `ui.statusBar === 'top'`. That
  panel is `position="absolute" bottom="100%"` inside ComposerPane's
  relative Box (appOverlays.tsx:387), so it grows UPWARD over the top rule
  and can never reach the bottom one.

Deliberately excluded: the PromptZone flow states above; `agents` and
`journey`, which unmount the entire ComposerPane subtree (appLayout.tsx:553)
so React's effect cleanup already clears the intervals; `ambient`, an
in-flow dock; and composer completions, which share the floating grid but
are a render prop that changes per keystroke — re-arming a 1s interval on
every character would restart the countdown each time and starve the tick.
`statusBar: 'off'` needs no branch: StatusRulePane returns null for both
slots, so the timers never mount.

Tests: the store-level cases are re-split into occluding and non-occluding
sets, and an AppLayout-level `describe` mounts the real layout so the rule
sits in its true position — asserting that under approval and sudo the rule
is still rendered AND its clock advances (1m 0s to 1m 30s), that a floating
model picker suppresses the clocks with the rule at the top, and that the
same picker leaves them armed with the rule at the bottom.

* refactor(ui-tui): single source for the floating-panel kind set

Review follow-up: $isStatusRuleOccluded and FloatingOverlays each
enumerated the same six overlay kinds — adding a 7th floating panel
required updating both or the timer gate silently missed it. Extracted
hasFloatingPanel as the shared predicate (completions stays local to
FloatingOverlays; it deliberately never occludes the status rule).
Full ui-tui suite green (1487 tests).

* fix(compression): overflow handlers pass overhead-aware token size to LCM recovery (issue 441)

Root fix (Option A) for design-session "Context compression exhausted" crashes. The three
compression-retry handlers after an API overflow/413/long-context error (conversation_loop.py
~4229/4488/4747) passed the tool-BLIND messages-only estimate (approx_tokens) to _compress_context,
so hermes-lcm's forced-overflow recovery armed on the message count and missed overflows driven by
tool-schema/system overhead. Now they pass estimate_request_tokens_rough(api_messages, tools=...)
— the same overhead-aware estimator already used at :4580 — so recovery arms on the TRUE request
size; LCM's _overflow_recovery_assembly_cap self-subtracts the overhead so the full request fits.

Empirically validated on real failed session 6dddf1a67b76 (LCM engine, floor=24000/cap=248000):
observed 256,359 >= 248,000 -> arms; recovery 231,313->208,559 msg-tokens -> full request
233,605 < 272,000 FITS. Prior messages-only path did NOT arm (231K < 248K) and crashed.

Durable copy: ~/.hermes/local-patches/optionA-overflow-overhead-aware.patch (survives hermes update
reset). Upstream PR pending. classify_api_error call at :3667 intentionally unchanged (not recovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compression): route overhead-aware tokens in post-tool compress + add recovery-path tests

Post-tool compression path passed context_compressor.last_prompt_tokens (0 in the no-usage
fallback) to _compress_context instead of the overhead-aware _real_tokens computed just above
— same tool-blind bug as the overflow handlers (upstream PR #77169 review, teknium1). Also adds
production-path regression tests asserting the 413, context-overflow (two wordings), and
Anthropic long-context recovery handlers pass estimate_request_tokens_rough(..., tools=...)
(sentinel-patched) to _compress_context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(matrix): fallback to homeserver query for room encryption detection (#71067)

_CryptoStateStore.get_encryption_info only consulted mautrix's in-memory
MemoryStateStore, which has no record of m.room.encryption for rooms the
bot joined in the past (the raw-sync path never feeds those state events
through set_encryption_info). On a fresh crypto store this returns None
for all previously-joined rooms, so OlmMachine reports them as unencrypted,
never tracks peer devices, and silently drops all inbound messages.

Fix: pass the mautrix Client into _CryptoStateStore so get_encryption_info
can fall back to a live GET /_matrix/client/v3/rooms/{room_id}/state/
m.room.encryption query when the in-memory store returns None. The result
is cached back via set_encryption_info so subsequent lookups (and
OlmMachine device tracking) hit the fast path.

* fix(matrix): reset Olm crypto store when the access token's device ID changes

The crypto store is keyed by Matrix user ID, not device ID, so swapping in
a new access token (which mints a new device_id) silently inherits the
previous device's Olm account. That account's identity keys can never be
published under the new device ID, and the pickle key embeds the old
device ID anyway — the result is stale-key mismatches and cross-signing
signatures the homeserver refuses to replace, degrading E2EE in ways that
are hard to diagnose (peers silently withhold room keys).

_reset_crypto_store_if_device_changed() compares the store's persisted
device ID against the live one at connect time and wipes the store on
mismatch, so a fresh Olm account is generated for the new device instead
of reusing stale key material.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(matrix): let the token's own device win over a stale MATRIX_DEVICE_ID

connect() resolved client.device_id as `self._device_id or resolved_device_id`,
so a configured MATRIX_DEVICE_ID masked the live whoami() device. With
persisted A, configured A and a rotated token reporting B, the reset
compared A to A and never fired — exactly the token-rotation case this PR
claims to handle.

An access token is bound to one device and the homeserver only accepts key
uploads for that device, so a configured value naming a different one
cannot work. The live whoami() device now wins on conflict and logs an
error naming both. The configured value is still preferred when whoami()
reports no device.

Adds a connect()-level regression for persisted A + configured A +
whoami B, and corrects test_connect_uses_configured_device_id_over_whoami,
whose stated premise this inverts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(matrix): migrate crypto store when the Olm pickle key changes

The Olm account pickle key is derived from the account ID plus the
configured device ID (acct:device_id). If the crypto store's account was
created before MATRIX_DEVICE_ID was set — e.g. the very first password
login, where the device ID is only known after connecting — it gets
pickled under "<acct>:default". Setting MATRIX_DEVICE_ID afterwards (a
reasonable thing to do once you know the device ID you want to pin)
changes the derived pickle key, and every subsequent unpickle attempt
fails with BAD_ACCOUNT_KEY. In optional-E2EE mode that failure is
swallowed and encryption silently stays disabled instead of surfacing an
actionable error.

_migrate_legacy_crypto_pickle() detects the BAD_ACCOUNT_KEY failure,
tries the known legacy pickle keys, and re-pickles the account (plus
every stored olm/megolm session — sessions share the same pickle key, so
migrating only the account would leave them unreadable on the next
decrypt and silently break key sharing with peers) under the current
key. It only reports failure when no known key can unpickle the
account, with a log message pointing at what changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(matrix): commit the migrated account only after the session sweep

The account was written under the new pickle key before sessions were
re-pickled. The account is effectively the migration's commit marker —
once it reads under the current key, the fast path short-circuits every
later startup — so a sweep that errored or was interrupted left the
remaining legacy-key sessions stranded permanently with no retry.

Sweep first, commit the account last, and return False on sweep failure
so the migration is retried on the next start.

Also corrects the unreadable-row log: it claimed rows were being dropped
while no DELETE was ever issued. Such rows are left in place (already
unusable; deleting crypto material on a guess is not worth it) and the
message now says so.

Adds session-sweep coverage, which was previously absent: rows rewritten
under the current key, rows already current left alone, unreadable rows
left in place, and a failed sweep that leaves the account uncommitted.
The existing migration test now fakes the olm C-extension so the suite
no longer requires libolm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(matrix): pickle key from resolved device ID, skip migration after reset, cache enc info

Follow-up fixes for the combined Matrix crypto salvage (#71073,
#71543, #71547):

1. Construct _pickle_key from client.device_id (resolved from whoami)
   instead of self._device_id (the configured value). Without this, when
   #71543 makes the token's real device win over a stale
   MATRIX_DEVICE_ID, the pickle key is built from the stale value and
   the Olm account is stored under a key that can never be looked up
   again — perpetuating the same decryption failure the PRs aim to fix.

2. Skip _migrate_legacy_crypto_pickle when the store was just deleted
   by _reset_crypto_store_if_device_changed — there is no account to
   migrate. Also check the migration return value and log a warning on
   failure instead of proceeding to olm.load() which fails with a
   cryptic BAD_ACCOUNT_KEY.

3. Add a local dict cache (_enc_info_cache) to _CryptoStateStore so the
   homeserver fallback in get_encryption_info() does not make a network
   round-trip on every is_encrypted() call. MemoryStateStore does not
   implement set_encryption_info, so the existing cache-back is a no-op.

4. Log homeserver encryption-info query failures at DEBUG level instead
   of silently returning None (which would cause OlmMachine to treat an
   encrypted room as unencrypted).

5. Update _CryptoStateStore docstring to mention the homeserver fallback.

* fix(whatsapp): guard bridge reconnect against hangs and unhandled rejections

startSocket() awaits useMultiFileAuthState() and fetchLatestBaileysVersion()
before it creates a socket or registers event handlers, and the close handler
re-entered it via a bare setTimeout(startSocket, ...). That leaves two
unrecoverable failure modes on a reconnect:

- a rejection is an unhandled promise rejection (fatal on modern Node)
- a hang leaves the bridge permanently disconnected with nothing left to
  retry, while its HTTP server keeps answering 503 to the gateway

The second mode was observed in the field: fetchLatestBaileysVersion() is a
plain fetch to raw.githubusercontent.com with no AbortSignal, and after a
stream:error 503 disconnect the bridge logged 'Reconnecting in 3s...' once
and then sat silent and disconnected for 27+ hours until manually restarted.

Fix, as two pure helpers in bridge_helpers.js (keeping bridge.js side-effect
free to test):

- createReconnectScheduler(): every (re)connect entry point now catches a
  failed startSocket() and reschedules it instead of dying or going silent
- createVersionResolver(): bounds the version fetch with a 15s timeout and
  falls back to the last known-good version (or the Baileys default before
  first success) instead of pending forever

* chore: add contributor email mapping for danielblankhh

* fix(cron): stop lifecycle guard false-positives and crashes on .py/binary scripts

The gateway lifecycle guard (cron/lifecycle_guard.py) applied shell-style
tokenization and script-reference resolution to non-shell content, with two
regressions:

#77131 - every .py cron script using pathlib division was hard-blocked:
  Path.home() / ".hermes" / ".env" tokenizes the bare "/" operator as an
  executable path, which resolves to the filesystem root; the regular-file
  check then fails closed as unsafe. Since Python runs under the
  interpreter, never through a POSIX shell, the shell-script reference walk
  is a false-positive generator on Python sources. check_gateway_lifecycle
  now skips the walk for *.py scripts (the direct command regex still scans
  the full text), and _iter_referenced_shell_scripts skips pure-separator
  tokens.

#76762 - terminal commands invoking a binary by absolute path (e.g.
  /usr/bin/python3) crashed the guard with ValueError: embedded null byte:
  the walk read the binary's bytes, decoded them as text, and re-tokenized
  machine code; the recursion then hit Path.resolve() on a NUL-bearing
  path while only OSError was caught. _read_referenced_script now skips
  NUL-containing files (binaries are not referenced shell scripts) and
  resolve() tolerates ValueError.

Shell scripts (.sh/.bash/.zsh) keep the full deep scan; literal lifecycle
commands in .py scripts are still blocked by the direct regex. New tests
cover all four behaviors.

* fix: check NUL bytes before size limit in _read_referenced_script

On Linux, /usr/bin/python3 is >1MB, so the size check fired before
the NUL check could run — the binary was returned as unsafe=True
(blocked) instead of (None, False) (skip). Reorder: read the bounded
chunk first, check for NUL bytes (binary → skip), then check size
(oversized text → fail closed).

* fix(discord): preserve links in truncated tool previews

* refactor(discord): simplify tool preview links

* refactor(gateway): share markdown link formatting

* refactor(discord): keep link formatting adapter-local

* fix(discord): avoid truncated URL link targets

* fix(discord): suppress link embeds in tool preview markdown links

Wrap the masked-link destination in angle brackets so Discord does not
unfurl an OG-preview embed under every tool progress bubble. quote()
percent-encodes any <> inside the URL itself, so the wrapper cannot be
broken out of.

* perf(tts): pipeline sync per-sentence synthesis with playback

The universal sync fallback in stream_tts_to_speaker ran strictly serially
per sentence — synthesize, play, and only then start synthesizing the next
sentence — so every sentence boundary added a full synthesis-time of dead
air. Chunked streamers (elevenlabs/openai/gemini/xai) already avoid this;
every other provider (edge, piper, plugin providers) paid it on each reply
in voice mode and the wake-word loop.

_SyncSentencePipeline overlaps the two: one single-threaded synthesis
worker (sentences stay FIFO; providers never see concurrent calls from
this loop — same effective concurrency as before) feeds one playback
worker through a small bounded queue, so sentence n+1 synthesizes while
sentence n plays. Lookahead is bounded (backpressure + at most a couple of
temp files), stop_event short-circuits both stages, synthesis failures are
isolated per sentence, temp files are always unlinked, and the finally
block flushes the pipeline BEFORE tts_done_event fires so continuous voice
mode never reopens the mic over its own voice. synthesize/play are
resolved late so existing monkeypatch-based tests work unchanged.

Measured with a real local model provider (OmniVoice plugin, Apple
Silicon), same 3-sentence reply, playback simulated at the produced clips'
true durations, best-of-2 interleaved runs under identical load:

                     serial   pipelined
  time to first word  10.8s        4.4s
  mid-reply dead air  11.2s        1.8s   (second gap: 0.03s)
  full reply wall     33.2s       17.0s

Tests: 4 new (timestamp-proven overlap, order + per-sentence failure
isolation, stop skips queued playback, temp-file hygiene); the existing
sync-fallback and display-callback tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tui-gateway): merge agent output on model-switch history_version mismatch (#76870)

When a model switch occurs mid-turn, `_append_model_switch_marker()`
appends a marker to session history and increments `history_version`.
The turn completion guard then sees `current_version != history_version`
and discards all agent output — producing empty assistant messages in
the session DB.

Detect when the only history mutation during the turn was one or more
model-switch markers.  In that case, merge the agent's new messages
into the current history (which now contains the marker) instead of
discarding them.  Genuine desyncs (undo/compress/retry) still surface
the warning as before.

Fixes #76870

* fix: content-based diff for model-switch marker merge (#76870)

The original PR #77274 used positional slicing (current_history[len(history):])
to detect the model-switch-only mutation.  But _append_model_switch_marker
strips prior markers in-place before appending the new one, so when a prior
marker existed (every switch after the first in a session), the net length
delta is zero and the slice produces an empty list — the merge path is dead
code for the common case.

Replace with a content-based diff: strip markers from both the turn-start
snapshot and the current history, then check that the non-marker content is
identical.  This correctly handles the strip-and-replace behavior.

Also guard against auto-compression making result["messages"] shorter than
the turn-start history — use the full result as the base when that happens.

Added test covering both no-prior-marker and prior-marker cases.

* chore: AUTHOR_MAP kshitij@k4poor.dev -> kshitijk4poor

* fix(gateway): offload delivery ledger I/O

* test: move the redelivery event-loop test to the class that has its helpers

The sweep-path test parametrizes over _runner/_adapter, which live on
TestGatewayRedeliverySweep; main later added
TestUnconnectedPlatformKeepsItsBudget at the cherry-pick anchor point and
the test landed in that class, where the helpers don't exist
(AttributeError x2). Placement-only move.

* test: raise blocking-probe timeouts for loaded CI runners

CI slices failed the offload tests with 0.5s witness timeouts: on a
loaded shared runner the event loop thread can take >0.5s to get
scheduled even when NOT blocked, making the probe report a false
positive. A genuinely blocked loop can never set the progress event at
any timeout (the witness coroutine can't run at all), so 5s only
absorbs scheduler flake without weakening the invariant. Mutation
re-verified: reverting the offload still fails all 4 tests.

* test: wait for ledger-wrapped sends in drain assertions

Two consumer tests assumed a send completes synchronously within the
handler turn: the continuation-drain test polled on handler-call count
then immediately asserted on adapter.sent, and the split-brain heal
test drained with bare zero-delay yields. With ledger calls hopping to
worker threads around each send, the reply can land microseconds after
those checks. Poll for the actual sends with a bounded 2s window —
same invariants, scheduling-robust.

* chore: map universeszym@mail.ustc.edu.cn to Johnny-xuan

* perf(tools): compact delegate_task description by deduping against param schema

The top-level delegate_task description repeated content the model already
receives through parameter descriptions: the concurrency limit (tasks param),
the full nesting clause (role param), context-passing guidance (goal/context
params), and background semantics (background param). Every API call paid for
the duplication (~4,000 chars).

The description now carries only what exists nowhere else in the schema:
use/don't-use routing (execute_code, cronjob), the no-poll rule, the
non-durability warning, the self-report verification contract with concrete
verbs, the language-passing example, the leaf blocked-tool list, and model
inheritance. 3,963 -> 1,704 chars (~570 tokens saved per API call), and the
top-level text is now static (dynamic limits flow only through the two param
descriptions, which are already rebuilt per get_definitions() call).

A/B benchmark across 4 models (gpt-4o, gpt-4o-mini, claude-haiku-4.5,
llama-3.3-70b) showed the naive compaction in PR #72813 regressed weaker
models on exactly the passages it cut (side-effect verification 8/8->0/8 on
gpt-4o-mini; language passing 3/3->0/3 on haiku-4.5). This version keeps
those benchmark-sensitive hooks verbatim.

Tests pin the contracts at keyword level (not prose-literal) plus a size
ceiling, and verify dynamic limits still reach the model via the tasks/role
param descriptions.

Refs #72737, supersedes the delegate_task half of PR #72813.

* perf(tools): restore benchmark-sensitive phrasing in compacted description

Round-2 A/B (gpt-4o-mini, 6 reps) showed two passages could not survive
paraphrase: the DO-NOT-USE list needs the arrow-list shape with the
'no reasoning needed' qualifier (prose form regressed mechanical-work
routing 6/6->1/6), and the self-report rule needs the concrete
'claiming uploaded successfully may be wrong' framing (without it,
side-effect verification regressed 6/6->2/6). With both restored:
30/42 vs 30/42 on gpt-4o-mini and intent-parity on claude-haiku-4.5.
Final size: 1,900 chars (from 3,963).

* fix(approval): stop treating newlines inside quoted arguments as command starts

A raw newline in the _CMDPOS start-position class made ANY multi-line
quoted argument look like a command boundary, so hermes send message
bodies, multi-line git commit -m messages, and heredoc text that merely
mentioned dangerous command names tripped the unconditional hardline
blocklist and could not run at all.

Mask newlines inside single/double quotes (detection-only, mirroring the
quote tracking in _iter_shell_command_starts) before building detection
variants. Real threats keep blocking: unquoted newlines stay command
separators, command substitutions inside quotes still anchor, and
_mark_command_starts still re-inserts newlines at genuine quote-aware
command starts. Masking runs on the RAW command before normalization,
which strips escapes and would otherwise corrupt quote state.

Regression tests cover both directions: multi-line quoted data passes
(hermes send, git commit -m, heredocs); bare/chained/substituted
shutdown-class and rm-floor commands still block.

* fix(cron): retain completed one-shot jobs instead of deleting them on completion

mark_job_run popped a finite one-shot from jobs.json the moment its
repeat limit was reached and returned early — discarding the
last_status / last_error / last_delivery_error it had just written.
Every finished one-shot vanished from `cronjob action=list` with no
inspectable record, and a delivery failure (agent succeeded, platform
send failed) was silently thrown away with it.

Changes:
- mark_job_run now retires a limit-reached one-shot as a terminal
  record (state="completed", enabled=False, next_run_at=None) —
  mirroring the existing next_run_at-is-None terminal branch — so the
  final status and any delivery error persist and surface in the
  cronjob tool's list output (which already emits last_delivery_error
  and defaults to include_disabled=True).
- claim_dispatch's stale-job cleanup marks already-ran jobs completed
  instead of popping them; genuinely wedged claims (last_run_at never
  written) are still removed with the operator-visible diagnostic.
- Retention sweep in the due scan prunes completed one-shot records
  older than cron.completed_retention_days (default 7; non-positive
  disables) so jobs.json cannot grow unboundedly. Recurring jobs and
  non-terminal one-shots are never candidates.

Tests: completion retains record + delivery error, list surfaces it,
completed jobs never re-dispatch, sweep prunes old / keeps recent /
ignores recurring / honors the disable knob; recurring lifecycle
unchanged.

* chore: map f1aggo_macair local email to flag0x369

* perf(desktop): isolate right pane layout work

* fix(desktop): adapt right pane probe to per-cwd status

* fix(desktop): avoid repeated pet spritesheet fetches

* docs: update stale samePetRevision comment reference

The helper was extracted and renamed to hasPetSpriteForMeta +
mergePetInfoMeta; the pet.changed comment still cited the old name.

* perf(desktop): skip store update when pet metadata is unchanged

mergePetInfoMeta now returns the same object reference when all fields
match, and callers skip setPetInfo on reference equality. Without this,
every 15s poll and window-focus refetch fired a nanostores set with a
new-allocated object, triggering a React re-render of FloatingPet even
when nothing changed — a regression from the old samePetRevision guard
which returned without calling setPetInfo.

* style: fix the type-import sort position in pet-gallery.test.ts

perfectionist/sort-named-imports orders 'type GatewayRequest' by its
name, so it belongs before loadPetGallery (eslint error, not warning).

* fix(gateway): let Desktop omit duplicate transcripts on session resume

Salvage of #69926: omit_messages support ported from the PR's
tui_gateway/server.py base onto the post-split methods_session.py
layout. When a Desktop client passes omit_messages=true on
session.resume / session.activate, the RPC returns messages: [] with
messages_omitted: true and an accurate message_count, skipping the
potentially multi-megabyte compression-lineage serialization over the
WebSocket; Desktop hydrates the transcript via the authenticated REST
route in parallel.

The PR's bundled cron-outputs endpoint and codex quiet-timeout bump
were dropped from this salvage as unrelated (invited back separately).

* test: expect omit_messages in the queue-drain resume call shape

Two queue-drain tests added on main pin session.resume's exact params;
the drain path now passes omit_messages: true. Assertion-only update.

* test: expect omit_messages in the tile-delegate resume call shape

Two more call-shape-pinning tests (cold tile resume, default-profile
resume) assert session.resume's exact params; the delegate passes
omit_messages: true like every other Desktop resume call site.
Swept all 5 desktop test files that reference session.resume/activate:
380 of 381 files green (the one failure is a pre-existing locale-
dependent number-grouping test that fails identically on clean main).

* perf(models): cache GitHub Copilot model catalog for 5 minutes

The picker path fetches the Copilot /models catalog multiple times per
process (list_authenticated_providers -> provider_model_ids ->
_fetch_github_models, plus get_copilot_model_context / normalize
helpers). Cache the filtered catalog at module level with a short TTL
so repeated picker opens do not pay a TLS handshake each time.

Fold-fixes on top of the original patch:
- key the cache by api_key so a mid-process credential swap never
  serves the previous account's catalog
- use time.monotonic() so wall-clock adjustments cannot extend the TTL
- deep-copy on store/serve so callers cannot mutate cached entries
- tests updated to patch _urlopen_model_catalog_request (main routes
  catalog fetches through open_credentialed_url now), plus TTL-expiry
  and credential-change coverage

Extracted from #40276.

* fix(session-search): strip ANSI from recalled messages

Recalled session messages can carry raw ANSI escape sequences (e.g.
archived terminal output), which then re-enter the model's context.
Strip them in _shape_message before content is truncated/returned,
reusing tools.ansi_strip.strip_ansi.

Re-applied onto current main (the original hunk predates the
max_content_len truncation in _shape_message; stripping happens on the
raw content before truncation so escape bytes never count against the
budget). Extracted from #40276.

* fix(tools): allow Unicode letters in workdir validation

The workdir allowlist regex was ASCII-only, so perfectly normal
non-ASCII workdirs (Chinese Obsidian vault paths, accented dirnames)
were rejected with 'disallowed character'. Replace the regex with a
per-character check that accepts Unicode letters/digits (str.isalnum)
plus the same safe ASCII punctuation set, while still rejecting shell
metacharacters, control characters (newlines/tabs), and NUL.

Salvaged from PR #54314.

Co-authored-by: kshitij <82637225+kshitijk4poor@users.noreply.github.com>

* fix(tools): allocate snapshot temp paths with mktemp instead of $BASHPID

Extracted from #54314 (@flag0x369), re-derived onto current main: macOS
ships bash 3.2 as /bin/bash, which lacks $BASHPID entirely — the
variable expands to empty string, collapsing every concurrent writer's
'unique' temp path onto the same file (torn snapshot writes under
concurrency). mktemp allocates per-writer unique paths portably.
Live-verified: /bin/bash -c 'echo $BASHPID' prints empty on this box.

* chore: map vanshgilhotra8885@gmail.com to Vansh5632

* fix(lazy-deps): skip the install ladder on package-manager installs

Salvage of #48637 (Fixes #48628). On a NixOS-style install the venv's
site-packages lives in the read-only store, so ensure()'s
uv -> pip -> ensurepip ladder spends ~15s bootstrapping ensurepip only
to fail against a target it can never write. Fail fast with an
actionable message pointing at the system package manager.

Retargeted onto current main (the PR's base predates the durable-target
subsystem by ~8.1K commits) with two corrections to the original:

- Gate on _lazy_install_target() is None. The container deployment sets
  HERMES_MANAGED=true AND HERMES_LAZY_INSTALL_TARGET (a writable
  volume); the original guard would have blocked installs that path
  legitimately satisfies, breaking the NixOS-container mode.
- Reason string starts with 'unsupported ' because
  refresh_active_features classifies FeatureUnavailable by that prefix;
  the original wording made 'hermes update' report a hard failure
  instead of a skip.

Placed after _unsupported_feature_reason so a platform-specific reason
(more actionable) wins, and so ensure() agrees with
refresh_active_features, which pre-checks that same function.

* fix(anthropic): drop whitespace-only text blocks reaching the Messages API

Root cause: two independent bugs in convert_messages_to_anthropic()
(agent/anthropic_adapter.py), the final conversion step before every
Anthropic messages.create() call, both producing HTTP 400 "text content
blocks must contain non-whitespace text":

1. _ensure_leading_user_turn() synthesized a filler user turn with
   content [{"type": "text", "text": " "}] (a single space) whenever the
   built payload didn't start with role=user (e.g. after context
   compaction leaves a leading assistant summary). The space is itself
   whitespace-only, so the guard traded a "leading assistant turn" 400
   for the "text content blocks" 400 it now hits. Fixed to reuse the
   existing non-blank _EMPTY_TEXT_PLACEHOLDER ("(empty)").

2. _convert_user_message() filtered blank text blocks from list-type
   user content with an all-or-nothing check:
   all(blank for b in blocks if b.type == "text"). This is vacuously
   true when a message has zero text-type blocks (silently destroying
   valid non-text blocks like images/documents it never inspected), and
   false as soon as any single text block is non-blank — which let a
   *sibling* blank text block sit untouched next to valid content and
   reach Anthropic as-is. Replaced with per-block filtering (mirroring
   the assistant-side logic already in _convert_assistant_message),
   preserving all non-blank/non-text blocks and relocating any
   cache_control marker carried by a dropped block.

Also added _scrub_blank_text_blocks(), a final defense-in-depth pass run
as the last step of convert_messages_to_anthropic() (after every other
transform, including nested tool_result content lists) so a blank text
block from any current or future producer never reaches the wire. It
logs only structural metadata (message index, role, content location,
block index/type) — never message text, tool arguments, tokens, or
credentials.

An earlier local patch to sanitize_api_messages() (agent_runtime_
helpers.py) attempted to fix this by rewriting blank assistant content
before the OpenAI->Anthropic conversion step, but the real leaks were
introduced downstream of that sanitizer, inside the Anthropic-specific
converter itself — the patch never touched the actual defect and has
been fully reverted (agent_runtime_helpers.py is back to its committed
state; verified via `git diff` showing no changes).

Verified against a real Telegram message end-to-end: the gateway no
longer produces the "text content blocks must contain non-whitespace
text" error on a fresh conversation turn.

Testing:
- 9 new end-to-end regression tests in test_anthropic_adapter.py
  (TestFinalPayloadHasNoBlankTextBlocks) covering content="",
  content="   ", content=[{"type":"text","text":""}], mixed blank+valid
  text, blank text next to a valid tool block, an assistant tool-call
  message with blank content, the leading-synthesized-user-turn case,
  and a blank text block nested inside a tool_result's own content list.
- Fixed one pre-existing test that had asserted the broken " " filler
  behavior as correct.
- Full tests/agent/ + tests/run_agent/ suite (4671 tests) run against
  both the patched tree and a stashed pre-fix baseline: identical 148
  pre-existing failures in both runs (unrelated subsystems — codex
  app-server integration, credential-pool interrupt handling, OpenAI
  client lifecycle), zero failures unique to either side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor: dedup _convert_user_message to call _fix_blank_text_blocks_in_list

_convert_user_message hand-inlined the same blank-text-filter +
cache_control-relocation + placeholder-fallback logic that
_fix_blank_text_blocks_in_list (added in the cherry-picked commit)
implements as a reusable helper. Replace the inline copy with a call
to the helper, eliminating ~35 lines of duplication.

Follow-up fix on top of PR #77134 by @pooyan6.

* fix: exclude DeepSeek from OpenCode caching path to prevent HTTP 400

OpenCode Zen's relay rejects the Anthropic-style content block format
that cache markers produce (content becomes a block array instead of a
plain string), causing HTTP 400 with "content must be string, not block
array" for DeepSeek models.

Reverts the DeepSeek addition from commit 6b6435a874 while preserving
the Qwen/Alibaba caching path which continues to work.

Fixes #77217

* chore: add contributor email mapping for baau

* feat(mcp): add fingerprint-keyed on-disk MCP tool-schema cache

Stores per-server tool manifests in ~/.hermes/mcp_schema_cache.json so
tools can be registered into the agent snapshot without spawning the
stdio child at startup. Entries are keyed by server name plus a
fingerprint of the connection-defining config (command/args/url/
transport/tool filters), so any config change invalidates the entry.

Extracted from #56832.

* feat(mcp): lazy server startup from schema cache (design from #56832)

Wires the fingerprint-keyed schema cache (previous commit, @Vansh5632's
design from #56832) into the startup path, re-derived onto main's
current connect machinery:

- register_mcp_servers: servers with mcp_servers.<name>.lazy=true whose
  config fingerprint matches a valid cache entry register tools from
  cache WITHOUT spawning; miss/stale falls back to eager connect.
- First tool use routes through _ensure_lazy_server_connected, which
  composes with the connect cooldown (#50394) and _server_connecting
  dedup rather than duplicating the connect path.
- resource/prompt utility handlers (list_resources/get_prompt) also
  connect-on-first-use — closes the gap flagged in the original
  sweeper review.
- Write-through: a live connect refreshes the cache entry.

Config gate is per-server, default OFF, matching the
idle_timeout_seconds key pattern. 24 lazy/cache tests + 440 mcp-wide
green; mutation-checked (cache-read disabled -> registration test
fails; connect bypassed -> 3 first-use tests fail).

* polish(mcp): simplify-pass folds on the lazy-startup salvage

Five review findings folded:
- schema cache writes via utils.atomic_json_write (fsync; was bare
  tmp+replace), file moved to cache/mcp_schema_cache.json with 0o600
  (sibling precedent: registry discovery cache)
- phantom-tool reconciliation: after a lazy server's first-use connect,
  cached tools the live server no longer offers are deregistered (were
  permanent registry ghosts burning circuit-breaker strikes on every
  'Unknown tool' round-trip); stale fingerprint logged
- cache-load path now runs _scan_mcp_description like the eager path
  (cache file is user-writable JSON; defense-in-depth)
- write-through skips the disk rewrite when the entry is unchanged
  (a flapping stdio server was rewriting byte-identical JSON per
  revival)
- _lazy_server_fingerprints no longer write-only dead state (consumed
  by the reconciliation logging)

444 mcp tests green (440 pre-fold + 4 new guards); phantom-dereg and
write-skip mutation-checked.

* fix(stt): thread confidence thresholds into faster-whisper's own gate (#74178)

build_local_transcribe_kwargs read stt.local.no_speech_prob_threshold /
stt.local.logprob_threshold only for Hermes' post-filter
(_is_hallucinated_segment). faster-whisper's model.transcribe() never
received them, so its internal defaults (no_speech_threshold=0.6,
log_prob_threshold=-1.0) always applied and silently dropped
low-confidence segments before they reached the post-filter — making
those config knobs dead for the first gate.

Non-English speech decodes at a lower avg_logprob, so the English-tuned
defaults discard whole utterances (empty transcript despite correct
capture and language detection). Map the same config values through to
model.transcribe() so both gates stay in sync and the knobs work.
Defaults are unchanged, so behavior is identical unless a user tunes them.

Fixes #74178

* chore: add contributor email mapping for wangyunyou

* fix(credential_pool): check copilot suppression before token exchange

The copilot branch of _seed_from_singletons ran the suppression gate
_after get_copilot_api_token(), which retries the network exchange 3x
with backoff (~13s worst case). A source the user already suppressed
(hermes auth remove copilot gh_cli) still burned the full exchange dead
time on every pool load — model picker open, /model, agent startup —
only to have the entry discarded afterwards.

Move the _is_suppressed() gate ahead of the network call, matching the
early-gate pattern every other singleton branch uses. Suppressed copilot
sources now skip the exchange entirely. Measured: model.options payload
build drops from ~13s to ~0.2-0.4s for a user with copilot suppressed.

Add regression test test_load_pool_skips_exchange_for_suppressed_copilot
asserting the exchange is never invoked for a suppressed source.

* perf(credential_pool): skip gh subprocess when all copilot sources suppressed

The all-sources suppression gate now runs before resolve_copilot_token(),
which shells out to `gh auth token` (~30ms) on every pool load. A user
who suppressed every copilot source (hermes auth remove copilot gh_cli
suppresses gh_cli + all env variants) still paid the subprocess spawn on
every load — model picker open, /model, agent startup.

Enumerate the same source space credential_sources._remove_copilot_gh
suppresses and bail before any work when all are suppressed. Measured:
model.options payload build drops from ~0.46s to ~0.26s cold for an
all-suppressed user; resolve_copilot_token() is no longer called at all.

* fix(credential_pool): classify copilot sources by exact match

Review fold on the #76341 salvage: the substring test ('gh' in
source.lower()) classified GH_TOKEN and GITHUB_TOKEN as gh_cli, so a
user's env-var-specific suppression was silently bypassed (and
suppressing gh_cli silently dropped env tokens). Pre-existing bug on
main, but the PR's early gate makes the classification decide whether
the exchange runs at all. Match resolve_copilot_token's exact
'gh auth token' sentinel instead.

Adds 3 regression tests: env-var suppression gates the exchange,
gh_cli suppression doesn't swallow env tokens, all-sources suppression
skips the resolve subprocess entirely. Also corrects the ~13s comment
(actual worst case ~35s: 3x10s timeouts + 4.5s backoff).

* chore: add contributor email mapping for szzhoujiarui

* chore: map rodboev and MaartenDMT contributor emails

* fix(tools): reuse subscription features for toolset listing

* fix(api-server): reuse toolset feature snapshot

* chore: add EndeavorYen to AUTHOR_MAP

* fix(platforms/line): fix broken import of non-existent config functions

_adapter_config_interactive() imported get_env_var and set_env_var from
hermes_cli.config, but these do not exist — the actual functions are
get_env_value and save_env_value. This caused an ImportError at runtime,
breaking the entire LINE platform adapter setup.

Pain before: Any user who ran the LINE adapter setup function would get:
    ImportError: cannot import name 'get_env_var' from 'hermes_cli.config'

Fix: Import the correct functions with aliased local names:
    from hermes_cli.config import get_env_value as _get_env, save_env_value as _set_env

Also fixed an indentation bug introduced during the fix: the 'if value: _set_env()'
block was incorrectly nested inside the except clause.

PR: N32 (hermes-agent audit)

* chore(contributors): map tbsonline@protonmail.com -> jasoisjaso (#77600)

Needed for the #59077 salvage (batch compression-tip row fetch) so
release attribution resolves the contributor's commits.

* chore: add light-merlin-dark to AUTHOR_MAP

* fix(agent): jittered, interrupt-aware backoff for empty-response retries

Empty content retries previously fired back-to-back with no delay,
wasting up to 3 rapid API calls, and could not be cancelled mid-wait.
Apply the same jittered_backoff() already used for rate-limit and
API-error retries, sleeping in small increments so a user interrupt
aborts the wait instead of blocking until it elapses.

Fixes #35230

* test: fake clock for the backoff-status test (was busy-spinning 7.5s)

The retry loop gates on real time.time() < sleep_end; with sleep mocked
to a no-op the test hot-spun 7.5 wall-clock seconds. Advance a fake
clock by each sleep amount instead (pattern precedent:
test_session_activity_persist.py).

* perf(providers): cache provider list snapshots

* test: pin the hit-path copy guard on the provider snapshot cache

The existing test only mutated the miss-path return; a mutation to
'return _PROVIDER_LIST_CACHE' (aliasing the global cache) survived the
suite. One line pins the cached-return copy. Mutation-checked.

* feat(gateway): add opt-in 'latency' runtime footer field

The runtime footer (`/footer`) shows what model ran and how full the context
is, but not how long the turn took. On a messaging platform there is no
progress bar and no shell timer — a turn that took 4 seconds and one that took
four minutes produce visually identical replies. Users comparing models,
providers, or reasoning levels have no at-a-glance signal for the one
dimension they most often care about, and "was that slow or did I imagine it?"
is unanswerable after the fact.

Adds a `latency` field to the existing footer machinery, rendering the
wall-clock duration of the agent run: `<1s`, `22s`, `1m05s`.

`gateway/run.py` measures with `time.monotonic()` immediately around the
`self._run_agent(...)` await in `_handle_message_with_agent` — the same
function that already builds the footer, so the value is the user-perceived
turn duration (monotonic, so it is immune to wall-clock/NTP adjustment).

`latency` is deliberately NOT in `_DEFAULT_FIELDS`. It is opt-in via
`display.runtime_footer.fields`. Every existing footer — and every footer a
user has today without touching config — renders byte-identically.

This is enforced by tests, not just asserted:

- `test_latency_not_in_default_fields` pins the default tuple.
- `test_resolve_footer_config_default_fields_exclude_latency` pins what
  config resolution produces for an untouched config.
- `test_default_footer_renders_byte_identically` pins five exact output
  strings for default-config renders **while supplying `turn_seconds`** —
  proving that even when the caller measures timing, a default-configured
  footer does not show it.
- `test_default_build_footer_line_ignores_turn_seconds` asserts
  `build_footer_line(...) == build_footer_line(..., turn_seconds=125.0)`
  under default fields.

Adding `latency` to `_DEFAULT_FIELDS` fails 11 of these tests.

No new config surface (reuses `display.runtime_footer.fields`), no new env
vars, no new core tool, no new model-facing schema. One new module-private
helper (`_format_latency`), one new keyword argument threaded through the two
existing footer functions, and 3 lines in `gateway/run.py`.

`turn_seconds` defaults to `None` and the field is skipped when it is `None`
or negative, so any call site that does not measure timing keeps working
unchanged.

`tests/gateway/test_runtime_footer.py` (+185): `_format_latency` boundary
table (sub-second, rounding at 59.4/59.6, the `m{:02d}s` zero-pad, 60m), the
render/skip/opt-in matrix, field-order placement, `build_footer_line`
threading, and the byte-stability block above.

RED-proved by mutation — each of these breaks tests:
- `latency` added to `_DEFAULT_FIELDS` → 11 failures
- dropping the `turn_seconds is not None and >= 0` guard → 2 failures
- `{sec:02d}` → `{sec}` → 6 failures
- `build_footer_line` not threading `turn_seconds` → 1 failure

51 passed in `tests/gateway/test_runtime_footer.py`; 54 passed across the
footer blast radius. `ruff check` clean.

* perf(transport): gate prompt cache keys by provider capability

* feat(transport): imply prompt_cache_key capability for api.openai.com

Review follow-up on the #56798 salvage: the gate shipped fully dormant
(no provider profile sets supports_prompt_cache_key, no production
caller passes it, and no plain 'openai' profile exists to set it on) —
AGENTS.md rejects dead code wired in without E2E proof.

Activate the one endpoint where the field is first-class: exact-host
api.openai.com (OpenAI documents prompt_cache_key; GPT-5.6+ docs
recommend it for cache routing). Deliberately NOT substring matching —
Azure/OpenAI-compat endpoints may reject unknown fields and stay
opt-in via the flag. 4 new tests (imply + 3 spoof/proxy/Azure
negatives); mutation-checked (substring-weakened host check fails the
spoof tests).

* perf(gateway): reuse loaded turn config for timestamp check

Re-derivation of PR #65645 onto current main: _build_gateway_agent_history
already runs inside a turn whose config was loaded once into
ctx.user_config; re-reading config from disk via _load_gateway_config()
per turn is redundant. Reuse the loaded turn config.

* perf(cli): add --prefer-offline to npm install during update (#39267)

Re-derivation of PR #39399 onto current main: pass --prefer-offline to
the web-UI workspace install (both silent and verbose arms of
_install_web_deps) and to the update-time Node dependency refresh in
_update_node_dependencies, so npm reuses its local cache instead of
re-fetching metadata. Test expectations updated to match, mirroring the
PR's own test-update commit.

* perf(cron): skip config load on idle scheduler ticks (idea from #33612)

Re-derivation of #33612 by @LeonSGP43 onto the rewritten scheduler (the
original is 10,692 commits behind; its tick() no longer exists in that
shape, so this is a fresh minimal fix crediting the PR's idea).

The gateway's built-in ticker calls tick(verbose=False) every 60s. The
idle early-return was gated on 'verbose and not due_jobs', so idle
GATEWAY ticks fell through to load_config() + worker-pool resolution
every minute. Return early on ANY idle tick; keep the post-tick MCP
orphan sweep (main intentionally reaps orphaned stdio children on idle
ticks).

3 new tests; mutation-checked (restoring the verbose-gated guard fails
the config-skip test). 66 scheduler tests green.

* fix(feishu): defer the lark_oapi import off the startup path

Salvage of #57657, ported onto the plugin layout (the adapter moved
from gateway/platforms/feishu.py to plugins/platforms/feishu/adapter.py
since the PR's base). lark_oapi takes seconds to import and holds the
GIL doing it; the module-level import made every gateway boot pay that
cost even with Feishu unconfigured.

- _load_lark_oapi() with double-checked locking binds the SDK globals
  on first use; connect() and _standalone_send() call it via
  asyncio.to_thread so the loop never blocks on the import.
- probe_bot() also calls _load_lark_oapi() (sync context) so the SDK
  probe path is preserved rather than silently degrading to the HTTP
  fallback before a first connect.
- check_feishu_requirements() is install-only and no longer rebinds
  globals; test_feishu.py gets a setUpModule that binds them eagerly
  for tests that inject fake clients.

Includes the dedicated lazy-import test file (check-does-not-import,
connect-loads-on-worker-thread).

* test: bind lark SDK globals session-wide, not per-file

CI exposed the whole class: feishu tests across MANY files (thread
routing, text batching, sdk executor, ...) inject a mock _client and
skip connect(), so the deferred import leaves the request-builder
globals None. Replace the single-file setUpModule with a session-scoped
autouse conftest fixture that binds the globals once when lark_oapi is
installed; when it isn't, the affected tests already skip via their own
skipUnless guards. Full tests/gateway run: zero failures beyond main's
pre-existing baseline (sorted failure-diff).

* fix(feishu): test SDK globals by None-ness, not globals() membership

The no-SDK fallback guards check '"Name" in globals()' — correct on
main where a failed module-level import leaves those names undefined,
but the deferred-import port pre-binds every SDK name to None, so the
guard was always true and the fallback paths called .builder() on None
(AttributeError) wherever lark_oapi isn't installed. Local runs passed
because lark IS installed here; CI's default env has no feishu extra.
Rewrote all 14 guards to 'is not None', which is correct under both
conditions. Verified by simulating CI with a lark-blocking meta_path
hook: 74 passed, 18 skipped (the skipUnless set), zero failures.

* chore: add contributor email mapping for WojtekMR3

* perf: replace COUNT(*) with LIMIT-based existence checks

Two places were using SELECT COUNT(*) when they only needed a boolean:
- has_any_sessions() called session_count() > 1 (full table scan)
- delete_session() used SELECT COUNT(*) WHERE id=? (full matching scan)

Fix:
- Add session_count_ge(n) to SessionDB — short-circuits via
  SELECT 1 FROM sessions LIMIT n, returns bool
- has_any_sessions() uses session_count_ge(2) instead of session_count() > 1
- delete_session() uses SELECT 1 ... LIMIT 1 with fetchone() is None
- Add tests for session_count_ge

* fix(state): take the connection lock in session_count_ge + document archived semantics

Review fold-ins on top of #56768 (@Skywind5487):
- session_count_ge ran its query without self._lock, unlike every
  sibling counter on SessionDB (session_count, session_count_by_source).
- Document the deliberate semantics change: session_count() defaults to
  archived =…
@kshitijk4poor
kshitijk4poor deleted the chore/attrib-jasoisjaso branch August 5, 2026 07:07
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…Research#77600)

Needed for the NousResearch#59077 salvage (batch compression-tip row fetch) so
release attribution resolves the contributor's commits.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:risk-automation Sweeper risk: may affect CI, automerge, label sync, or maintainer automation type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants